diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..f464dc340d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +**/node_modules +**/.pnpm-store +**/dist +**/.next +**/.turbo +**/.cache +**/__pycache__ +**/*.pyc +**/.mypy_cache +**/.ruff_cache +.git +.github +*.md +!web/README.md +!api/README.md diff --git a/.gitattributes b/.gitattributes index a32a39f65c..fcce5d2670 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,7 @@ # them. *.sh text eol=lf + +# 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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e55602ff7a..0f63db196e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -18,6 +18,10 @@ # Docs /docs/ @crazywoola +# CLI +/cli/ @langgenius/maintainers +/.github/workflows/cli-tests.yml @langgenius/maintainers + # Backend (default owner, more specific rules below will override) /api/ @QuantumGhost diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000000..2aea3c9e0e --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,88 @@ +name: CLI Release + +on: + workflow_dispatch: + push: + tags: + - 'difyctl-v*' + +concurrency: + group: cli-release-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + name: build standalone binaries (all targets) + runs-on: depot-ubuntu-24.04 + if: github.repository == 'langgenius/dify' + permissions: + contents: write + defaults: + run: + shell: bash + working-directory: ./cli + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Setup Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2 + with: + bun-version: latest + + - name: Read cli/package.json + id: manifest + run: | + version=$(node -p "require('./package.json').version") + channel=$(node -p "require('./package.json').difyctl.channel") + minDify=$(node -p "require('./package.json').difyctl.compat.minDify") + maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify") + { + echo "version=$version" + echo "channel=$channel" + echo "minDify=$minDify" + echo "maxDify=$maxDify" + } >> "$GITHUB_OUTPUT" + + - name: Validate manifest + run: scripts/release-validate-manifest.sh + + - name: Install cross-arch native prebuilds + # Re-installs node_modules with every @napi-rs/keyring platform variant + # so `bun build --compile` can embed the right .node into each target. + working-directory: ./ + run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile + + - name: Compile standalone binaries (all targets) + env: + CLI_VERSION: ${{ steps.manifest.outputs.version }} + DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }} + DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }} + DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }} + run: | + DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ + DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ + pnpm build:bin + + - name: Generate sha256 checksum file + env: + CLI_VERSION: ${{ steps.manifest.outputs.version }} + run: scripts/release-write-checksums.sh + + - name: Publish GitHub Release + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: difyctl-v${{ steps.manifest.outputs.version }} + name: difyctl ${{ steps.manifest.outputs.version }} + prerelease: ${{ steps.manifest.outputs.channel != 'stable' }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + cli/dist/bin/difyctl-v* diff --git a/.github/workflows/cli-smoke.yml b/.github/workflows/cli-smoke.yml new file mode 100644 index 0000000000..045ff8e71a --- /dev/null +++ b/.github/workflows/cli-smoke.yml @@ -0,0 +1,60 @@ +name: CLI Smoke (live dify) + +on: + workflow_dispatch: + inputs: + dify_version: + description: "Dify image tag to test against (e.g. 1.7.0)" + type: string + required: true + cli_ref: + description: "Git ref to build the cli from (default: current branch)" + type: string + required: false + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - name: Checkout cli ref + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.cli_ref || github.ref }} + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Bring up dify + env: + DIFY_VERSION: ${{ inputs.dify_version }} + run: | + cd docker + cp .env.example .env + DIFY_API_IMAGE_TAG="$DIFY_VERSION" \ + DIFY_WEB_IMAGE_TAG="$DIFY_VERSION" \ + docker compose up -d api worker web db redis + for i in $(seq 1 60); do + if curl -fsS http://localhost:5001/health >/dev/null 2>&1; then + echo "dify api ready after ${i}s" + break + fi + sleep 1 + done + + - name: Run smoke against live dify + working-directory: ./cli + run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001 + + - name: Dump dify logs on failure + if: failure() + run: | + cd docker + docker compose logs api worker web --tail=200 diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml new file mode 100644 index 0000000000..8cd053651a --- /dev/null +++ b/.github/workflows/cli-tests.yml @@ -0,0 +1,46 @@ +name: CLI Tests + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + +permissions: + contents: read + +concurrency: + group: cli-tests-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: CLI Tests + runs-on: depot-ubuntu-24.04 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + defaults: + run: + shell: bash + working-directory: ./cli + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: CI pipeline (typecheck, lint, coverage, build) + run: pnpm ci + + - name: Report coverage + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + directory: cli/coverage + flags: cli + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 788bd8940c..34dda7fb14 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -42,6 +42,7 @@ jobs: runs-on: depot-ubuntu-24.04 outputs: api-changed: ${{ steps.changes.outputs.api }} + cli-changed: ${{ steps.changes.outputs.cli }} e2e-changed: ${{ steps.changes.outputs.e2e }} web-changed: ${{ steps.changes.outputs.web }} vdb-changed: ${{ steps.changes.outputs.vdb }} @@ -62,6 +63,18 @@ jobs: - 'docker/generate_docker_compose' - 'docker/ssrf_proxy/**' - 'docker/volumes/sandbox/conf/**' + cli: + - 'cli/**' + - 'packages/tsconfig/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'eslint.config.mjs' + - '.npmrc' + - '.nvmrc' + - '.github/workflows/cli-tests.yml' + - '.github/workflows/cli-docker-build.yml' + - '.github/actions/setup-web/**' web: - 'web/**' - 'packages/**' @@ -184,6 +197,66 @@ jobs: echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2 exit 1 + cli-tests-run: + name: Run CLI Tests + needs: + - pre_job + - check-changes + if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed == 'true' + uses: ./.github/workflows/cli-tests.yml + secrets: inherit + + cli-tests-skip: + name: Skip CLI Tests + needs: + - pre_job + - check-changes + if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true' + runs-on: depot-ubuntu-24.04 + steps: + - name: Report skipped CLI tests + run: echo "No CLI-related changes detected; skipping CLI tests." + + cli-tests: + name: CLI Tests + if: ${{ always() }} + needs: + - pre_job + - check-changes + - cli-tests-run + - cli-tests-skip + runs-on: depot-ubuntu-24.04 + steps: + - name: Finalize CLI Tests status + env: + SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }} + TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }} + RUN_RESULT: ${{ needs.cli-tests-run.result }} + SKIP_RESULT: ${{ needs.cli-tests-skip.result }} + run: | + if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then + echo "CLI tests were skipped because this workflow run duplicated a successful or newer run." + exit 0 + fi + + if [[ "$TESTS_CHANGED" == 'true' ]]; then + if [[ "$RUN_RESULT" == 'success' ]]; then + echo "CLI tests ran successfully." + exit 0 + fi + + echo "CLI tests were required but finished with result: $RUN_RESULT" >&2 + exit 1 + fi + + if [[ "$SKIP_RESULT" == 'success' ]]; then + echo "CLI tests were skipped because no CLI-related files changed." + exit 0 + fi + + echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2 + exit 1 + web-tests-run: name: Run Web Tests needs: diff --git a/.gitignore b/.gitignore index 881f841da1..207e2600e7 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,12 @@ venv/ ENV/ env.bak/ venv.bak/ + +# cli/ has a src/env/ module (DIFY_* registry) — don't treat it as a venv +!/cli/src/env/ +!/cli/src/commands/env/ +# cli/scripts/lib/ holds TS build helpers (resolve-buildinfo etc.) — don't treat as Python lib/ +!/cli/scripts/lib/ .conda/ # Spyder project settings @@ -247,6 +253,7 @@ scripts/stress-test/reports/ # settings *.local.json *.local.md +*.local.toml # Code Agent Folder .qoder/* diff --git a/api/app_factory.py b/api/app_factory.py index 565c7fefd8..e9094fd8ad 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -159,6 +159,7 @@ def initialize_extensions(app: DifyApp): ext_logstore, ext_mail, ext_migrate, + ext_oauth_bearer, ext_orjson, ext_otel, ext_proxy_fix, @@ -203,6 +204,7 @@ def initialize_extensions(app: DifyApp): ext_enterprise_telemetry, ext_request_logging, ext_session_factory, + ext_oauth_bearer, ] for ext in extensions: short_name = ext.__name__.split(".")[-1] diff --git a/api/configs/deploy/__init__.py b/api/configs/deploy/__init__.py index 63f4dfba63..145e9fc563 100644 --- a/api/configs/deploy/__init__.py +++ b/api/configs/deploy/__init__.py @@ -1,3 +1,5 @@ +from typing import Literal + from pydantic import Field from pydantic_settings import BaseSettings @@ -23,7 +25,7 @@ class DeploymentConfig(BaseSettings): default=False, ) - EDITION: str = Field( + EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field( description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')", default="SELF_HOSTED", ) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index d2e6d144ac..5083bb11f6 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -525,6 +525,44 @@ class HttpConfig(BaseSettings): def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]: return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",") + OPENAPI_ENABLED: bool = Field( + description=( + "Enable the /openapi/v1/* endpoint group used by difyctl and other " + "programmatic clients. Set to true to activate; disabled by default." + ), + validation_alias=AliasChoices("OPENAPI_ENABLED"), + default=False, + ) + + inner_OPENAPI_CORS_ALLOW_ORIGINS: str = Field( + description=( + "Comma-separated allowlist for /openapi/v1/* CORS. " + "Default empty = same-origin only. Browser-cookie routes within " + "the group reject cross-origin OPTIONS regardless of this list." + ), + validation_alias=AliasChoices("OPENAPI_CORS_ALLOW_ORIGINS"), + default="", + ) + + @computed_field + def OPENAPI_CORS_ALLOW_ORIGINS(self) -> list[str]: + return [o for o in self.inner_OPENAPI_CORS_ALLOW_ORIGINS.split(",") if o] + + inner_OPENAPI_KNOWN_CLIENT_IDS: str = Field( + description=( + "Comma-separated client_id values accepted at " + "POST /openapi/v1/oauth/device/code. New CLIs / SDKs added here " + "without code changes. Unknown client_id returns 400 unsupported_client." + ), + validation_alias=AliasChoices("OPENAPI_KNOWN_CLIENT_IDS"), + default="difyctl", + ) + + @computed_field # type: ignore[misc] + @property + def OPENAPI_KNOWN_CLIENT_IDS(self) -> frozenset[str]: + return frozenset(c for c in self.inner_OPENAPI_KNOWN_CLIENT_IDS.split(",") if c) + HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field( ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10 ) @@ -900,6 +938,17 @@ class AuthConfig(BaseSettings): default=86400, ) + ENABLE_OAUTH_BEARER: bool = Field( + description="Enable OAuth bearer authentication (device-flow + Service API /v1/* bearer middleware).", + default=True, + ) + + OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field( + description="Per-token rate limit on /openapi/v1/* (requests per minute). " + "Bucket keyed on sha256(token), shared across api replicas via Redis.", + default=60, + ) + class ModerationConfig(BaseSettings): """ @@ -1186,6 +1235,14 @@ class CeleryScheduleTasksConfig(BaseSettings): description="Enable scheduled workflow run cleanup task", default=False, ) + ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK: bool = Field( + description="Enable scheduled cleanup of revoked/expired OAuth access-token rows past retention.", + default=True, + ) + OAUTH_ACCESS_TOKEN_RETENTION_DAYS: PositiveInt = Field( + description="Days to retain revoked OAuth access-token rows before deletion.", + default=30, + ) ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field( description="Enable mail clean document notify task", default=False, diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py new file mode 100644 index 0000000000..998829098a --- /dev/null +++ b/api/controllers/openapi/__init__.py @@ -0,0 +1,128 @@ +from flask import Blueprint +from flask_restx import Namespace + +from libs.device_flow_security import attach_anti_framing +from libs.external_api import ExternalApi + +bp = Blueprint("openapi", __name__, url_prefix="/openapi/v1") +attach_anti_framing(bp) + +api = ExternalApi( + bp, + version="1.0", + title="OpenAPI", + description="User-scoped programmatic API (bearer auth)", +) + +openapi_ns = Namespace("openapi", description="User-scoped operations", path="/") + +# Register response/query models BEFORE importing controller modules so that +# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names. +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.openapi._models import ( + AccountPayload, + AccountResponse, + AppDescribeInfo, + AppDescribeQuery, + AppDescribeResponse, + AppInfoResponse, + AppListQuery, + AppListResponse, + AppListRow, + AppRunRequest, + DeviceCodeRequest, + DeviceCodeResponse, + DeviceLookupQuery, + DeviceLookupResponse, + DeviceMutateRequest, + DeviceMutateResponse, + DevicePollRequest, + MessageMetadata, + PermittedExternalAppsListQuery, + PermittedExternalAppsListResponse, + RevokeResponse, + ServerVersionResponse, + SessionListResponse, + SessionRow, + TagItem, + UsageInfo, + WorkflowRunData, + WorkspaceDetailResponse, + WorkspaceListResponse, + WorkspacePayload, + WorkspaceSummaryResponse, +) +from fields.file_fields import FileResponse + +register_schema_models( + openapi_ns, + AppDescribeQuery, + AppListQuery, + AppRunRequest, + DeviceCodeRequest, + DevicePollRequest, + DeviceLookupQuery, + DeviceMutateRequest, + PermittedExternalAppsListQuery, +) +register_response_schema_models( + openapi_ns, + TagItem, + UsageInfo, + MessageMetadata, + AppListRow, + AppListResponse, + AppInfoResponse, + AppDescribeInfo, + AppDescribeResponse, + WorkflowRunData, + AccountPayload, + WorkspacePayload, + AccountResponse, + SessionRow, + SessionListResponse, + PermittedExternalAppsListResponse, + RevokeResponse, + WorkspaceSummaryResponse, + WorkspaceListResponse, + WorkspaceDetailResponse, + DeviceCodeResponse, + DeviceLookupResponse, + DeviceMutateResponse, + FileResponse, + ServerVersionResponse, +) + +from . import ( + _meta, + account, + app_run, + apps, + apps_permitted_external, + files, + human_input_form, + index, + oauth_device, + oauth_device_sso, + workflow_events, + workspaces, +) + +# Request models are imported from _models.py and registered above. + +__all__ = [ + "_meta", + "account", + "app_run", + "apps", + "apps_permitted_external", + "files", + "human_input_form", + "index", + "oauth_device", + "oauth_device_sso", + "workflow_events", + "workspaces", +] + +api.add_namespace(openapi_ns) diff --git a/api/controllers/openapi/_audit.py b/api/controllers/openapi/_audit.py new file mode 100644 index 0000000000..c31bae28ab --- /dev/null +++ b/api/controllers/openapi/_audit.py @@ -0,0 +1,66 @@ +"""Audit emission for openapi app-run endpoints. + +Pattern: logger.info with extra={"audit": True, "event": "app.run.openapi", ...} +matches the existing oauth_device convention. The EE OTel exporter consults +its own allowlist to decide whether to ship the line. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +EVENT_APP_RUN_OPENAPI = "app.run.openapi" +EVENT_OPENAPI_WRONG_SURFACE_DENIED = "openapi.wrong_surface_denied" + + +def emit_app_run( + *, + app_id: str, + tenant_id: str, + caller_kind: str, + mode: str, + surface: str, +) -> None: + logger.info( + "audit: %s app_id=%s tenant_id=%s caller_kind=%s mode=%s surface=%s", + EVENT_APP_RUN_OPENAPI, + app_id, + tenant_id, + caller_kind, + mode, + surface, + extra={ + "audit": True, + "event": EVENT_APP_RUN_OPENAPI, + "app_id": app_id, + "tenant_id": tenant_id, + "caller_kind": caller_kind, + "mode": mode, + "surface": surface, + }, + ) + + +def emit_wrong_surface( + *, + subject_type: str | None, + attempted_path: str, + client_id: str | None, + token_id: str | None, +) -> None: + logger.warning( + "audit: %s subject_type=%s attempted_path=%s", + EVENT_OPENAPI_WRONG_SURFACE_DENIED, + subject_type, + attempted_path, + extra={ + "audit": True, + "event": EVENT_OPENAPI_WRONG_SURFACE_DENIED, + "subject_type": subject_type, + "attempted_path": attempted_path, + "client_id": client_id, + "token_id": token_id, + }, + ) diff --git a/api/controllers/openapi/_input_schema.py b/api/controllers/openapi/_input_schema.py new file mode 100644 index 0000000000..1b638200b8 --- /dev/null +++ b/api/controllers/openapi/_input_schema.py @@ -0,0 +1,143 @@ +"""Server-side JSON Schema derivation from Dify `user_input_form`.""" + +from __future__ import annotations + +from typing import Any, cast + +from controllers.service_api.app.error import AppUnavailableError +from models import App +from models.model import AppMode + +JSON_SCHEMA_DRAFT = "https://json-schema.org/draft/2020-12/schema" + +EMPTY_INPUT_SCHEMA: dict[str, Any] = { + "$schema": JSON_SCHEMA_DRAFT, + "type": "object", + "properties": {}, + "required": [], +} + +_CHAT_FAMILY = frozenset({AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}) + + +def _file_object_shape() -> dict[str, Any]: + """Single-file value shape. Forward-compat placeholder; refine when file-API contract pins.""" + return { + "type": "object", + "properties": { + "type": {"type": "string"}, + "transfer_method": {"type": "string"}, + "url": {"type": "string"}, + "upload_file_id": {"type": "string"}, + }, + "additionalProperties": True, + } + + +def _row_to_schema(row_type: str, row: dict[str, Any]) -> dict[str, Any] | None: + label = row.get("label") or row.get("variable", "") + base: dict[str, Any] = {"title": label} if label else {} + + if row_type in ("text-input", "paragraph"): + out: dict[str, Any] = {"type": "string"} | base + max_length = row.get("max_length") + if isinstance(max_length, int) and max_length > 0: + out["maxLength"] = max_length + return out + + if row_type == "select": + return {"type": "string"} | base | {"enum": list(row.get("options") or [])} + + if row_type == "number": + return {"type": "number"} | base + + if row_type == "file": + return _file_object_shape() | base + + if row_type == "file-list": + return { + "type": "array", + "items": _file_object_shape(), + } | base + + return None + + +def _form_to_jsonschema(form: list[dict[str, Any]]) -> tuple[dict[str, Any], list[str]]: + """Translate a user_input_form row list into (properties, required-list). + + Each row is a single-key dict: `{"text-input": {variable, label, required, ...}}`. + Unknown variable types are skipped (forward-compat). + """ + properties: dict[str, Any] = {} + required: list[str] = [] + for row in form: + if not isinstance(row, dict) or len(row) != 1: + continue + ((row_type, row_body),) = row.items() + if not isinstance(row_body, dict): + continue + variable = row_body.get("variable") + if not variable: + continue + schema = _row_to_schema(row_type, row_body) + if schema is None: + continue + properties[variable] = schema + if row_body.get("required"): + required.append(variable) + return properties, required + + +def resolve_app_config(app: App) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Resolve `(features_dict, user_input_form)` for parameters / schema derivation. + + Raises `AppUnavailableError` on misconfigured apps. + """ + if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + workflow = app.workflow + if workflow is None: + raise AppUnavailableError() + return ( + workflow.features_dict, + cast(list[dict[str, Any]], workflow.user_input_form(to_old_structure=True)), + ) + + app_model_config = app.app_model_config + if app_model_config is None: + raise AppUnavailableError() + features_dict = cast(dict[str, Any], app_model_config.to_dict()) + return features_dict, cast(list[dict[str, Any]], features_dict.get("user_input_form", [])) + + +def build_input_schema(app: App) -> dict[str, Any]: + """Derive Draft 2020-12 JSON Schema from `user_input_form` + app mode. + + chat / agent-chat / advanced-chat: top-level `query` (required, minLength=1) + `inputs` object. + completion / workflow: `inputs` object only. + Raises `AppUnavailableError` on misconfigured apps. + """ + _, user_input_form = resolve_app_config(app) + inputs_props, inputs_required = _form_to_jsonschema(user_input_form) + + properties: dict[str, Any] = {} + required: list[str] = [] + + if app.mode in _CHAT_FAMILY: + properties["query"] = {"type": "string", "minLength": 1} + required.append("query") + + properties["inputs"] = { + "type": "object", + "properties": inputs_props, + "required": inputs_required, + "additionalProperties": False, + } + required.append("inputs") + + return { + "$schema": JSON_SCHEMA_DRAFT, + "type": "object", + "properties": properties, + "required": required, + } diff --git a/api/controllers/openapi/_meta.py b/api/controllers/openapi/_meta.py new file mode 100644 index 0000000000..e1c380bf55 --- /dev/null +++ b/api/controllers/openapi/_meta.py @@ -0,0 +1,23 @@ +"""Meta endpoint: `GET /openapi/v1/_version` — no auth. + +Returns the server's project version and edition so the difyctl CLI can probe +compatibility without needing to be logged in. Mirrors the `_health` endpoint +in `index.py`. +""" + +from flask_restx import Resource + +from configs import dify_config +from controllers.openapi import openapi_ns +from controllers.openapi._models import ServerVersionResponse + + +@openapi_ns.route("/_version") +class VersionApi(Resource): + @openapi_ns.response(200, "Server version", openapi_ns.models[ServerVersionResponse.__name__]) + def get(self): + edition = dify_config.EDITION if dify_config.EDITION in ("SELF_HOSTED", "CLOUD") else "SELF_HOSTED" + return ServerVersionResponse( + version=dify_config.project.version, + edition=edition, + ).model_dump(mode="json") diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py new file mode 100644 index 0000000000..128a937549 --- /dev/null +++ b/api/controllers/openapi/_models.py @@ -0,0 +1,344 @@ +"""Shared response substructures for openapi endpoints.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from libs.helper import UUIDStrOrEmpty, uuid_value +from models.model import AppMode + +# Server-side cap on `limit` query param for /openapi/v1/* list endpoints. +MAX_PAGE_LIMIT = 200 + + +class UsageInfo(BaseModel): + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class MessageMetadata(BaseModel): + usage: UsageInfo | None = None + retriever_resources: list[dict[str, Any]] = [] + + +class PaginationEnvelope[T](BaseModel): + """Canonical pagination envelope for `/openapi/v1/*` list endpoints.""" + + page: int + limit: int + total: int + has_more: bool + data: list[T] + + @classmethod + def build(cls, *, page: int, limit: int, total: int, items: list[T]) -> PaginationEnvelope[T]: + return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items) + + +class TagItem(BaseModel): + name: str + + +class AppListRow(BaseModel): + id: str + name: str + description: str | None = None + mode: AppMode + tags: list[TagItem] = [] + updated_at: str | None = None + created_by_name: str | None = None + workspace_id: str | None = None + workspace_name: str | None = None + + +class AppListResponse(BaseModel): + page: int + limit: int + total: int + has_more: bool + data: list[AppListRow] + + +class PermittedExternalAppsListResponse(BaseModel): + page: int + limit: int + total: int + has_more: bool + data: list[AppListRow] + + +class AppInfoResponse(BaseModel): + id: str + name: str + description: str | None = None + mode: str + author: str | None = None + tags: list[TagItem] = [] + + +class AppDescribeInfo(AppInfoResponse): + updated_at: str | None = None + service_api_enabled: bool + is_agent: bool = False + + +class AppDescribeResponse(BaseModel): + info: AppDescribeInfo | None = None + parameters: dict[str, Any] | None = None + input_schema: dict[str, Any] | None = None + + +class ChatMessageResponse(BaseModel): + event: str + task_id: str + id: str + message_id: str + conversation_id: str + mode: str + answer: str + metadata: MessageMetadata = Field(default_factory=MessageMetadata) + created_at: int + + +class CompletionMessageResponse(BaseModel): + event: str + task_id: str + id: str + message_id: str + mode: str + answer: str + metadata: MessageMetadata = Field(default_factory=MessageMetadata) + created_at: int + + +class WorkflowRunData(BaseModel): + id: str + workflow_id: str + status: str + outputs: dict[str, Any] = Field(default_factory=dict) + error: str | None = None + elapsed_time: float | None = None + total_tokens: int | None = None + total_steps: int | None = None + created_at: int | None = None + finished_at: int | None = None + + +class WorkflowRunResponse(BaseModel): + workflow_run_id: str + task_id: str + mode: Literal["workflow"] = "workflow" + data: WorkflowRunData + + +class AccountPayload(BaseModel): + id: str + email: str + name: str + + +class WorkspacePayload(BaseModel): + id: str + name: str + role: str + + +class AccountResponse(BaseModel): + subject_type: str + subject_email: str | None = None + subject_issuer: str | None = None + account: AccountPayload | None = None + workspaces: list[WorkspacePayload] = [] + default_workspace_id: str | None = None + + +class SessionRow(BaseModel): + id: str + prefix: str + client_id: str + device_label: str + created_at: str | None = None + last_used_at: str | None = None + expires_at: str | None = None + + +class SessionListResponse(BaseModel): + page: int + limit: int + total: int + has_more: bool + data: list[SessionRow] + + +class RevokeResponse(BaseModel): + status: str + + +class WorkspaceSummaryResponse(BaseModel): + id: str + name: str + role: str + status: str + current: bool + + +class WorkspaceListResponse(BaseModel): + workspaces: list[WorkspaceSummaryResponse] + + +class WorkspaceDetailResponse(BaseModel): + id: str + name: str + role: str + status: str + current: bool + created_at: str | None = None + + +class DeviceCodeResponse(BaseModel): + device_code: str + user_code: str + verification_uri: str + expires_in: int + interval: int + + +class DeviceLookupResponse(BaseModel): + valid: bool + expires_in_remaining: int = 0 + client_id: str | None = None + + +class DeviceMutateResponse(BaseModel): + status: str + + +class ServerVersionResponse(BaseModel): + """Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.""" + + version: str + edition: Literal["SELF_HOSTED", "CLOUD"] + + +class AppDescribeQuery(BaseModel): + """`?fields=` allow-list for GET /apps//describe. + + Empty / omitted → all blocks. Unknown member → ValidationError → 422. + """ + + model_config = ConfigDict(extra="forbid") + + fields: set[str] | None = None + workspace_id: str | None = None + + @field_validator("workspace_id", mode="before") + @classmethod + def _validate_workspace_id(cls, v: object) -> str | None: + if v is None or v == "": + return None + if not isinstance(v, str): + raise ValueError("workspace_id must be a string") + try: + import uuid as _uuid + + _uuid.UUID(v) + except ValueError: + raise ValueError("workspace_id must be a valid UUID") + return v + + @field_validator("fields", mode="before") + @classmethod + def _parse_fields(cls, v: object) -> set[str] | None: + if v is None or v == "": + return None + if not isinstance(v, str): + raise ValueError("fields must be a comma-separated string") + _ALLOWED_DESCRIBE_FIELDS = frozenset({"info", "parameters", "input_schema"}) + members = {m.strip() for m in v.split(",") if m.strip()} + unknown = members - _ALLOWED_DESCRIBE_FIELDS + if unknown: + raise ValueError(f"unknown field(s): {sorted(unknown)}") + return members + + +class AppListQuery(BaseModel): + """mode is a closed enum.""" + + workspace_id: str + page: int = Field(1, ge=1) + limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) + mode: AppMode | None = None + name: str | None = Field(None, max_length=200) + tag: str | None = Field(None, max_length=100) + + +class AppRunRequest(BaseModel): + inputs: dict[str, Any] + query: str | None = None + files: list[dict[str, Any]] | None = None + conversation_id: UUIDStrOrEmpty | None = None + auto_generate_name: bool = True + workflow_id: str | None = None + workspace_id: UUIDStrOrEmpty | None = None + + @field_validator("conversation_id", mode="before") + @classmethod + def _normalize_conv(cls, value: str | None) -> str | None: + if isinstance(value, str): + value = value.strip() + if not value: + return None + try: + return uuid_value(value) + except ValueError as exc: + raise ValueError("conversation_id must be a valid UUID") from exc + + +class DeviceCodeRequest(BaseModel): + client_id: str + device_label: str + + +class DevicePollRequest(BaseModel): + device_code: str + client_id: str + + +class DeviceLookupQuery(BaseModel): + user_code: str + + +class DeviceMutateRequest(BaseModel): + user_code: str + + +class PermittedExternalAppsListQuery(BaseModel): + """Strict (extra='forbid').""" + + model_config = ConfigDict(extra="forbid") + + page: int = Field(1, ge=1) + limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) + mode: AppMode | None = None + name: str | None = Field(None, max_length=200) + + +_EMAIL_FIELD = Field(min_length=3, max_length=320, pattern=r"^[^@\s]+@[^@\s]+$") + + +class ExtSubjectAssertionClaims(BaseModel): + email: str = _EMAIL_FIELD + issuer: str = Field(min_length=1, max_length=255) + user_code: str = Field(min_length=1, max_length=32) + nonce: str = Field(min_length=1, max_length=128) + + +class ApprovalGrantClaimsPayload(BaseModel): + subject_email: str = _EMAIL_FIELD + subject_issuer: str = Field(min_length=1, max_length=255) + user_code: str = Field(min_length=1, max_length=32) + nonce: str = Field(min_length=1, max_length=128) + csrf_token: str = Field(min_length=1, max_length=128) diff --git a/api/controllers/openapi/account.py b/api/controllers/openapi/account.py new file mode 100644 index 0000000000..602d7e7ab4 --- /dev/null +++ b/api/controllers/openapi/account.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from flask import request +from flask_restx import Resource +from werkzeug.exceptions import BadRequest, NotFound + +from controllers.openapi import openapi_ns +from controllers.openapi._models import ( + MAX_PAGE_LIMIT, + AccountPayload, + AccountResponse, + PaginationEnvelope, + RevokeResponse, + SessionListResponse, + SessionRow, + WorkspacePayload, +) +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + AuthContext, + SubjectType, + get_auth_ctx, + validate_bearer, +) +from libs.rate_limit import ( + LIMIT_ME_PER_ACCOUNT, + LIMIT_ME_PER_EMAIL, + enforce, +) +from services.account_service import AccountService, TenantService +from services.oauth_device_flow import ( + list_active_sessions, + revoke_oauth_token, + token_belongs_to_subject, +) + + +@openapi_ns.route("/account") +class AccountApi(Resource): + @openapi_ns.response(200, "Account info", openapi_ns.models[AccountResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + def get(self): + ctx = get_auth_ctx() + + if ctx.subject_type == SubjectType.EXTERNAL_SSO: + enforce(LIMIT_ME_PER_EMAIL, key=f"subject:{ctx.subject_email}") + else: + enforce(LIMIT_ME_PER_ACCOUNT, key=f"account:{ctx.account_id}") + + if ctx.subject_type == SubjectType.EXTERNAL_SSO: + return AccountResponse( + subject_type=ctx.subject_type, + subject_email=ctx.subject_email, + subject_issuer=ctx.subject_issuer, + account=None, + workspaces=[], + default_workspace_id=None, + ).model_dump(mode="json") + + account = AccountService.get_account_by_id(db.session, str(ctx.account_id)) if ctx.account_id else None + memberships = TenantService.get_account_memberships(db.session, str(ctx.account_id)) if ctx.account_id else [] + default_ws_id = _pick_default_workspace(memberships) + + return AccountResponse( + subject_type=ctx.subject_type, + subject_email=ctx.subject_email or (account.email if account else None), + account=_account_payload(account) if account else None, + workspaces=[_workspace_payload(m) for m in memberships], + default_workspace_id=default_ws_id, + ).model_dump(mode="json") + + +@openapi_ns.route("/account/sessions/self") +class AccountSessionsSelfApi(Resource): + @openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + def delete(self): + ctx = get_auth_ctx() + _require_oauth_subject(ctx) + revoke_oauth_token(db.session, redis_client, str(ctx.token_id)) + return RevokeResponse(status="revoked").model_dump(mode="json"), 200 + + +@openapi_ns.route("/account/sessions") +class AccountSessionsApi(Resource): + @openapi_ns.response(200, "Session list", openapi_ns.models[SessionListResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + def get(self): + ctx = get_auth_ctx() + now = datetime.now(UTC) + page = int(request.args.get("page", "1")) + limit = min(int(request.args.get("limit", "100")), MAX_PAGE_LIMIT) + + all_rows = list_active_sessions(db.session, ctx, now) + + total = len(all_rows) + sliced = all_rows[(page - 1) * limit : page * limit] + + items = [ + SessionRow( + id=str(r.id), + prefix=r.prefix, + client_id=r.client_id, + device_label=r.device_label, + created_at=_iso(r.created_at), + last_used_at=_iso(r.last_used_at), + expires_at=_iso(r.expires_at), + ) + for r in sliced + ] + + return ( + PaginationEnvelope.build(page=page, limit=limit, total=total, items=items).model_dump(mode="json"), + 200, + ) + + +@openapi_ns.route("/account/sessions/") +class AccountSessionByIdApi(Resource): + @openapi_ns.response(200, "Session revoked", openapi_ns.models[RevokeResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + def delete(self, session_id: str): + ctx = get_auth_ctx() + _require_oauth_subject(ctx) + + # 404 (not 403) on cross-subject so the endpoint doesn't leak + # token IDs that belong to other subjects. + if not token_belongs_to_subject(db.session, session_id, ctx): + raise NotFound("session not found") + + revoke_oauth_token(db.session, redis_client, session_id) + return RevokeResponse(status="revoked").model_dump(mode="json"), 200 + + +def _require_oauth_subject(ctx: AuthContext) -> None: + if not ctx.source.startswith("oauth"): + raise BadRequest( + "this endpoint revokes OAuth bearer tokens; use /openapi/v1/personal-access-tokens/self for PATs" + ) + + +def _iso(dt: datetime | None) -> str | None: + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=UTC) + return dt.isoformat().replace("+00:00", "Z") + + +def _pick_default_workspace(memberships) -> str | None: + if not memberships: + return None + for join, tenant in memberships: + if getattr(join, "current", False): + return str(tenant.id) + return str(memberships[0][1].id) + + +def _workspace_payload(row) -> WorkspacePayload: + join, tenant = row + return WorkspacePayload(id=str(tenant.id), name=tenant.name, role=getattr(join, "role", "")) + + +def _account_payload(account) -> AccountPayload: + return AccountPayload(id=str(account.id), email=account.email, name=account.name) diff --git a/api/controllers/openapi/app_run.py b/api/controllers/openapi/app_run.py new file mode 100644 index 0000000000..95a26d50fa --- /dev/null +++ b/api/controllers/openapi/app_run.py @@ -0,0 +1,165 @@ +"""POST /openapi/v1/apps//run — mode-agnostic runner.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from typing import Any + +from flask import request +from flask_restx import Resource +from pydantic import ValidationError +from werkzeug.exceptions import BadRequest, HTTPException, InternalServerError, NotFound, UnprocessableEntity + +import services +from controllers.openapi import openapi_ns +from controllers.openapi._audit import emit_app_run +from controllers.openapi._models import AppRunRequest +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE +from controllers.service_api.app.error import ( + AppUnavailableError, + CompletionRequestError, + ConversationCompletedError, + ProviderModelCurrentlyNotSupportError, + ProviderNotInitializeError, + ProviderQuotaExceededError, +) +from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from core.app.apps.base_app_queue_manager import AppQueueManager +from core.app.entities.app_invoke_entities import InvokeFrom +from core.errors.error import ( + ModelCurrentlyNotSupportError, + ProviderTokenNotInitError, + QuotaExceededError, +) +from extensions.ext_redis import redis_client +from graphon.graph_engine.manager import GraphEngineManager +from graphon.model_runtime.errors.invoke import InvokeError +from libs import helper +from libs.oauth_bearer import Scope +from models.model import App, AppMode +from services.app_generate_service import AppGenerateService +from services.errors.app import ( + IsDraftWorkflowError, + WorkflowIdFormatError, + WorkflowNotFoundError, +) +from services.errors.llm import InvokeRateLimitError + +logger = logging.getLogger(__name__) + + +@contextmanager +def _translate_service_errors() -> Iterator[None]: + try: + yield + except WorkflowNotFoundError as ex: + raise NotFound(str(ex)) + except (IsDraftWorkflowError, WorkflowIdFormatError) as ex: + raise BadRequest(str(ex)) + except services.errors.conversation.ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + except services.errors.conversation.ConversationCompletedError: + raise ConversationCompletedError() + except services.errors.app_model_config.AppModelConfigBrokenError: + logger.exception("App model config broken.") + raise AppUnavailableError() + except ProviderTokenNotInitError as ex: + raise ProviderNotInitializeError(ex.description) + except QuotaExceededError: + raise ProviderQuotaExceededError() + except ModelCurrentlyNotSupportError: + raise ProviderModelCurrentlyNotSupportError() + except InvokeRateLimitError as ex: + raise InvokeRateLimitHttpError(ex.description) + except InvokeError as e: + raise CompletionRequestError(e.description) + + +def _generate(app: App, caller: Any, args: dict[str, Any], streaming: bool): + return AppGenerateService.generate( + app_model=app, + user=caller, + args=args, + invoke_from=InvokeFrom.OPENAPI, + streaming=streaming, + ) + + +def _run_chat(app: App, caller: Any, payload: AppRunRequest): + if not payload.query or not payload.query.strip(): + raise UnprocessableEntity("query_required_for_chat") + args = payload.model_dump(exclude_none=True) + with _translate_service_errors(): + return _generate(app, caller, args, streaming=True) + + +def _run_completion(app: App, caller: Any, payload: AppRunRequest): + args = payload.model_dump(exclude_none=True) + args["auto_generate_name"] = False + args.setdefault("query", "") + with _translate_service_errors(): + return _generate(app, caller, args, streaming=True) + + +def _run_workflow(app: App, caller: Any, payload: AppRunRequest): + if payload.query is not None: + raise UnprocessableEntity("query_not_supported_for_workflow") + args = payload.model_dump(exclude={"query", "conversation_id", "auto_generate_name"}, exclude_none=True) + with _translate_service_errors(): + return _generate(app, caller, args, streaming=True) + + +_DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = { + AppMode.CHAT: _run_chat, + AppMode.AGENT_CHAT: _run_chat, + AppMode.ADVANCED_CHAT: _run_chat, + AppMode.COMPLETION: _run_completion, + AppMode.WORKFLOW: _run_workflow, +} + + +@openapi_ns.route("/apps//run") +class AppRunApi(Resource): + @openapi_ns.expect(openapi_ns.models[AppRunRequest.__name__]) + @openapi_ns.response(200, "Run result (SSE stream)") + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def post(self, app_id: str, app_model: App, caller, caller_kind: str): + body = request.get_json(silent=True) or {} + try: + payload = AppRunRequest.model_validate(body) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + handler = _DISPATCH.get(app_model.mode) + if handler is None: + raise UnprocessableEntity("mode_not_runnable") + + try: + stream_obj = handler(app_model, caller, payload) + except HTTPException: + raise + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + emit_app_run( + app_id=app_model.id, + tenant_id=app_model.tenant_id, + caller_kind=caller_kind, + mode=str(app_model.mode), + surface="apps", + ) + + return helper.compact_generate_response(stream_obj) + + +@openapi_ns.route("/apps//tasks//stop") +class AppRunTaskStopApi(Resource): + @openapi_ns.response(200, "Task stopped") + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def post(self, app_id: str, task_id: str, app_model: App, caller, caller_kind: str): + AppQueueManager.set_stop_flag_no_user_check(task_id) + GraphEngineManager(redis_client).send_stop_command(task_id) + return {"result": "success"} diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py new file mode 100644 index 0000000000..8a3fc81809 --- /dev/null +++ b/api/controllers/openapi/apps.py @@ -0,0 +1,270 @@ +"""GET /openapi/v1/apps and per-app reads. + +Decorator order: `method_decorators` is innermost-first. `validate_bearer` +is last → outermost → publishes the auth ContextVar before `require_scope` +reads it. +""" + +from __future__ import annotations + +import uuid as _uuid +from typing import Any, cast + +from flask import request +from flask_restx import Resource +from pydantic import ValidationError +from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity + +from controllers.common.fields import Parameters +from controllers.common.schema import query_params_from_model +from controllers.openapi import openapi_ns +from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config +from controllers.openapi._models import ( + AppDescribeInfo, + AppDescribeQuery, + AppDescribeResponse, + AppListQuery, + AppListResponse, + AppListRow, + TagItem, +) +from controllers.openapi.auth.surface_gate import accept_subjects +from controllers.service_api.app.error import AppUnavailableError +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from extensions.ext_database import db +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + AuthContext, + Scope, + SubjectType, + get_auth_ctx, + require_scope, + require_workspace_member, + validate_bearer, +) +from models import App +from services.account_service import TenantService +from services.app_service import AppListParams, AppService +from services.tag_service import TagService + +_APPS_READ_DECORATORS = [ + require_scope(Scope.APPS_READ), + accept_subjects(SubjectType.ACCOUNT), + validate_bearer(accept=ACCEPT_USER_ANY), +] + +_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"}) + + +_EMPTY_PARAMETERS: dict[str, Any] = { + "opening_statement": None, + "suggested_questions": [], + "user_input_form": [], + "file_upload": None, + "system_parameters": {}, +} + + +class AppReadResource(Resource): + """Base for per-app read endpoints; subclasses call `_load()` for SSO/membership/exists checks.""" + + method_decorators = _APPS_READ_DECORATORS + + def _load(self, app_id: str, workspace_id: str | None = None) -> tuple[App, AuthContext]: + ctx: AuthContext = get_auth_ctx() + + try: + parsed_uuid = _uuid.UUID(app_id) + is_uuid = True + except ValueError: + parsed_uuid = None + is_uuid = False + + if is_uuid: + # ``str(parsed_uuid)`` normalises to the canonical dashed form. + app = AppService.get_visible_app_by_id(db.session, str(parsed_uuid)) + if app is None: + raise NotFound("app not found") + else: + if not workspace_id: + raise UnprocessableEntity("workspace_id is required for name-based lookup") + matches = AppService.find_visible_apps_by_name(db.session, name=app_id, tenant_id=workspace_id) + if len(matches) == 0: + raise NotFound("app not found") + if len(matches) > 1: + lines = [f"app name {app_id!r} is ambiguous — re-run with a UUID:\n\n"] + lines.append(f" {'ID':<36} {'MODE':<12} NAME\n") + for m in matches: + lines.append(f" {str(m.id):<36} {str(m.mode.value):<12} {m.name}\n") + raise Conflict("".join(lines)) + app = matches[0] + + require_workspace_member(ctx, str(app.tenant_id)) + return app, ctx + + +def parameters_payload(app: App) -> dict: + """Mirrors service_api/app/app.py::AppParameterApi response body.""" + features_dict, user_input_form = resolve_app_config(app) + parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) + return Parameters.model_validate(parameters).model_dump(mode="json") + + +@openapi_ns.route("/apps//describe") +class AppDescribeApi(AppReadResource): + @openapi_ns.doc(params=query_params_from_model(AppDescribeQuery)) + @openapi_ns.response(200, "App description", openapi_ns.models[AppDescribeResponse.__name__]) + def get(self, app_id: str): + try: + query = AppDescribeQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + app, _ = self._load(app_id, workspace_id=query.workspace_id) + + requested = query.fields + want_info = requested is None or "info" in requested + want_params = requested is None or "parameters" in requested + want_schema = requested is None or "input_schema" in requested + + info = ( + AppDescribeInfo( + id=str(app.id), + name=app.name, + mode=app.mode, + description=app.description, + tags=[TagItem(name=t.name) for t in app.tags], + author=app.author_name, + updated_at=app.updated_at.isoformat() if app.updated_at else None, + service_api_enabled=bool(app.enable_api), + is_agent=app.mode in ("agent-chat", "advanced-chat"), + ) + if want_info + else None + ) + + parameters: dict[str, Any] | None = None + input_schema: dict[str, Any] | None = None + if want_params: + try: + parameters = parameters_payload(app) + except AppUnavailableError: + parameters = dict(_EMPTY_PARAMETERS) + if want_schema: + try: + input_schema = build_input_schema(app) + except AppUnavailableError: + input_schema = dict(EMPTY_INPUT_SCHEMA) + + return ( + AppDescribeResponse( + info=info, + parameters=parameters, + input_schema=input_schema, + ).model_dump(mode="json", exclude_none=False), + 200, + ) + + +@openapi_ns.route("/apps") +class AppListApi(Resource): + method_decorators = _APPS_READ_DECORATORS + + @openapi_ns.doc(params=query_params_from_model(AppListQuery)) + @openapi_ns.response(200, "App list", openapi_ns.models[AppListResponse.__name__]) + def get(self): + ctx: AuthContext = get_auth_ctx() + + try: + query: AppListQuery = AppListQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + workspace_id = query.workspace_id + require_workspace_member(ctx, workspace_id) + + empty = ( + AppListResponse(page=query.page, limit=query.limit, total=0, has_more=False, data=[]).model_dump( + mode="json" + ), + 200, + ) + + if query.name: + try: + parsed_uuid = _uuid.UUID(query.name) + except ValueError: + parsed_uuid = None + else: + parsed_uuid = None + + tenant_name: str | None = None + if parsed_uuid is not None: + app: App | None = AppService.get_visible_app_by_id(db.session, str(parsed_uuid)) + if app is None or str(app.tenant_id) != workspace_id: + return empty + tenant_name = TenantService.get_tenant_name(db.session, workspace_id) + item = AppListRow( + id=str(app.id), + name=app.name, + description=app.description, + mode=app.mode, + tags=[TagItem(name=t.name) for t in app.tags], + updated_at=app.updated_at.isoformat() if app.updated_at else None, + created_by_name=getattr(app, "author_name", None), + workspace_id=str(workspace_id), + workspace_name=tenant_name, + ) + env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item]) + return env.model_dump(mode="json"), 200 + + tag_ids: list[str] | None = None + if query.tag: + tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag) + if not tags: + return empty + tag_ids = [tag.id for tag in tags] + + params = AppListParams( + page=query.page, + limit=query.limit, + mode=query.mode.value if query.mode else "all", # type:ignore + name=query.name, + tag_ids=tag_ids, + status="normal", + # Visibility gate pushed into the query — pagination.total stays + # consistent across pages because invisible rows never count. + openapi_visible=True, + ) + + pagination = AppService().get_paginate_apps(str(ctx.account_id), workspace_id, params) + if pagination is None: + return empty + + tenant_name = None + if pagination.items: + tenant_name = TenantService.get_tenant_name(db.session, workspace_id) + + items = [ + AppListRow( + id=str(r.id), + name=r.name, + description=r.description, + mode=r.mode, + tags=[TagItem(name=t.name) for t in r.tags], + updated_at=r.updated_at.isoformat() if r.updated_at else None, + created_by_name=getattr(r, "author_name", None), + workspace_id=str(workspace_id), + workspace_name=tenant_name, + ) + for r in pagination.items + ] + + env = AppListResponse( + page=query.page, + limit=query.limit, + total=cast(int, pagination.total), + has_more=query.page * query.limit < cast(int, pagination.total), + data=items, + ) + return env.model_dump(mode="json"), 200 diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py new file mode 100644 index 0000000000..9359dca228 --- /dev/null +++ b/api/controllers/openapi/apps_permitted_external.py @@ -0,0 +1,102 @@ +"""GET /openapi/v1/permitted-external-apps — external-subject app discovery (EE only). + +`dfoe_` (External SSO) callers reach apps gated by ACL access-mode +(public / sso_verified). License-gated: CE deploys never enable the +EE blueprint chain so this module is unreachable there. +""" + +from __future__ import annotations + +from flask import request +from flask_restx import Resource +from pydantic import ValidationError +from werkzeug.exceptions import UnprocessableEntity + +from controllers.openapi import openapi_ns +from controllers.openapi._models import ( + AppListRow, + PermittedExternalAppsListQuery, + PermittedExternalAppsListResponse, +) +from controllers.openapi.auth.surface_gate import accept_subjects +from extensions.ext_database import db +from libs.device_flow_security import enterprise_only +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + Scope, + SubjectType, + require_scope, + validate_bearer, +) +from models import App +from services.account_service import TenantService +from services.app_service import AppService +from services.enterprise.app_permitted_service import list_permitted_apps +from services.openapi.license_gate import license_required + + +@openapi_ns.route("/permitted-external-apps") +class PermittedExternalAppsListApi(Resource): + method_decorators = [ + require_scope(Scope.APPS_READ_PERMITTED_EXTERNAL), + license_required, + accept_subjects(SubjectType.EXTERNAL_SSO), + validate_bearer(accept=ACCEPT_USER_ANY), + enterprise_only, + ] + + @openapi_ns.response( + 200, "Permitted external apps list", openapi_ns.models[PermittedExternalAppsListResponse.__name__] + ) + def get(self): + try: + query = PermittedExternalAppsListQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + page_result = list_permitted_apps( + page=query.page, + limit=query.limit, + mode=query.mode.value if query.mode else None, + name=query.name, + ) + + if not page_result.app_ids: + env = PermittedExternalAppsListResponse( + page=query.page, limit=query.limit, total=page_result.total, has_more=False, data=[] + ) + return env.model_dump(mode="json"), 200 + + apps_by_id: dict[str, App] = { + str(a.id): a for a in AppService.find_visible_apps_by_ids(db.session, page_result.app_ids) + } + tenant_ids = list({str(a.tenant_id) for a in apps_by_id.values()}) + tenants_by_id = {str(t.id): t for t in TenantService.get_tenants_by_ids(db.session, tenant_ids)} + + items: list[AppListRow] = [] + for app_id in page_result.app_ids: + app = apps_by_id.get(app_id) + if not app or app.status != "normal": + continue + tenant = tenants_by_id.get(str(app.tenant_id)) + items.append( + AppListRow( + id=str(app.id), + name=app.name, + description=app.description, + mode=app.mode, + tags=[], # tenant-scoped; not surfaced cross-tenant + updated_at=app.updated_at.isoformat() if app.updated_at else None, + created_by_name=None, # cross-tenant author leak prevention + workspace_id=str(app.tenant_id), + workspace_name=tenant.name if tenant else None, + ) + ) + env = PermittedExternalAppsListResponse( + page=query.page, + limit=query.limit, + total=page_result.total, + has_more=query.page * query.limit < page_result.total, + data=items, + ) + return env.model_dump(mode="json"), 200 diff --git a/api/controllers/openapi/auth/__init__.py b/api/controllers/openapi/auth/__init__.py new file mode 100644 index 0000000000..17ac5493d0 --- /dev/null +++ b/api/controllers/openapi/auth/__init__.py @@ -0,0 +1,3 @@ +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE + +__all__ = ["OAUTH_BEARER_PIPELINE"] diff --git a/api/controllers/openapi/auth/composition.py b/api/controllers/openapi/auth/composition.py new file mode 100644 index 0000000000..973ddd75a2 --- /dev/null +++ b/api/controllers/openapi/auth/composition.py @@ -0,0 +1,46 @@ +"""`OAUTH_BEARER_PIPELINE` — the auth scheme for openapi `/run` endpoints. + +Endpoints attach via `@OAUTH_BEARER_PIPELINE.guard(scope=…)`. No alternative +paths. Read endpoints (`/apps`, `/info`, `/parameters`, `/describe`) skip +the pipeline and use `validate_bearer + require_scope + require_workspace_member` +inline — they don't need `AppAuthzCheck`/`CallerMount`. +""" + +from __future__ import annotations + +from controllers.openapi.auth.pipeline import Pipeline +from controllers.openapi.auth.steps import ( + AppAuthzCheck, + AppResolver, + BearerCheck, + CallerMount, + ScopeCheck, + SurfaceCheck, + WorkspaceMembershipCheck, +) +from controllers.openapi.auth.strategies import ( + AccountMounter, + AclStrategy, + AppAuthzStrategy, + EndUserMounter, + MembershipStrategy, +) +from libs.oauth_bearer import SubjectType +from services.feature_service import FeatureService + + +def _resolve_app_authz_strategy() -> AppAuthzStrategy: + if FeatureService.get_system_features().webapp_auth.enabled: + return AclStrategy() + return MembershipStrategy() + + +OAUTH_BEARER_PIPELINE = Pipeline( + BearerCheck(), + SurfaceCheck(accepted=frozenset({SubjectType.ACCOUNT})), + ScopeCheck(), + AppResolver(), + WorkspaceMembershipCheck(), + AppAuthzCheck(_resolve_app_authz_strategy), + CallerMount(AccountMounter(), EndUserMounter()), +) diff --git a/api/controllers/openapi/auth/context.py b/api/controllers/openapi/auth/context.py new file mode 100644 index 0000000000..95013627f0 --- /dev/null +++ b/api/controllers/openapi/auth/context.py @@ -0,0 +1,68 @@ +"""Mutable per-request context for the openapi auth pipeline. + +Every field starts None / empty and is filled in by a step. The pipeline +is the only thing that should construct or mutate Context — handlers +read populated values via the decorator's kwargs unpacking. + +Context is intentionally decoupled from Flask's ``Request``: the pipeline +guard extracts whatever transport-level inputs the steps need (bearer +token, path params) at the boundary and writes them into Context fields, +so steps stay testable without a request object and won't leak coupling +to a specific framework. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from contextvars import Token +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Protocol + +from werkzeug.exceptions import Unauthorized + +from libs.oauth_bearer import AuthContext, Scope, SubjectType + +if TYPE_CHECKING: + from models import App, Tenant + + +@dataclass +class Context: + required_scope: Scope + bearer_token: str | None = None + path_params: Mapping[str, str] = field(default_factory=dict) + subject_type: SubjectType | None = None + subject_email: str | None = None + subject_issuer: str | None = None + account_id: uuid.UUID | None = None + scopes: frozenset[Scope] = field(default_factory=frozenset) + token_id: uuid.UUID | None = None + token_hash: str | None = None + cached_verified_tenants: dict[str, bool] | None = None + source: str | None = None + expires_at: datetime | None = None + app: App | None = None + tenant: Tenant | None = None + caller: object | None = None + caller_kind: Literal["account", "end_user"] | None = None + auth_ctx_reset_token: Token[AuthContext] | None = None + + @property + def must_tenant(self) -> Tenant: + if not self.tenant: + raise Unauthorized("tenant is not associated") + return self.tenant + + @property + def must_subject_type(self) -> SubjectType: + if not self.subject_type: + raise Unauthorized("subject_type unset — BearerCheck did not run") + return self.subject_type + + +class Step(Protocol): + """One responsibility. Mutate ctx or raise to short-circuit.""" + + def __call__(self, ctx: Context) -> None: ... diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py new file mode 100644 index 0000000000..096b1b7ea3 --- /dev/null +++ b/api/controllers/openapi/auth/pipeline.py @@ -0,0 +1,51 @@ +"""Pipeline IS the auth scheme. + +`Pipeline.guard(scope=…)` is the only attachment point for endpoints — +that is the design lock-in: forgetting an auth layer is structurally +impossible because there is no "sometimes wrap, sometimes don't" choice. +""" + +from __future__ import annotations + +from functools import wraps + +from flask import request + +from controllers.openapi.auth.context import Context, Step +from libs.oauth_bearer import Scope, extract_bearer, reset_auth_ctx + + +class Pipeline: + def __init__(self, *steps: Step) -> None: + self._steps = steps + + def run(self, ctx: Context) -> None: + for step in self._steps: + step(ctx) + + def guard(self, *, scope: Scope): + def decorator(view): + @wraps(view) + def decorated(*args, **kwargs): + # Extract transport-level inputs at the boundary so steps + # stay decoupled from Flask's request object. + ctx = Context( + required_scope=scope, + bearer_token=extract_bearer(request), + path_params=dict(request.view_args or {}), + ) + try: + self.run(ctx) + kwargs.update( + app_model=ctx.app, + caller=ctx.caller, + caller_kind=ctx.caller_kind, + ) + return view(*args, **kwargs) + finally: + if ctx.auth_ctx_reset_token is not None: + reset_auth_ctx(ctx.auth_ctx_reset_token) + + return decorated + + return decorator diff --git a/api/controllers/openapi/auth/steps.py b/api/controllers/openapi/auth/steps.py new file mode 100644 index 0000000000..40a168b489 --- /dev/null +++ b/api/controllers/openapi/auth/steps.py @@ -0,0 +1,170 @@ +"""Pipeline steps. Each is one responsibility. + +`BearerCheck` is the only step that touches the token registry; downstream +steps see only the populated `Context`. `BearerCheck` also publishes the +resolved identity to the openapi auth ``ContextVar`` (the same one the +decorator-level :func:`libs.oauth_bearer.validate_bearer` writes to) so the +surface gate and any handler reading the request-scoped context has a single +source of truth across both auth-attach paths. The reset token is stashed +on `ctx.auth_ctx_reset_token`; `Pipeline.guard` resets the ContextVar in +its `finally` so worker-thread reuse can't leak identity across requests. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized + +from configs import dify_config +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.strategies import AppAuthzStrategy, CallerMounter +from controllers.openapi.auth.surface_gate import check_surface +from extensions.ext_database import db +from libs.oauth_bearer import ( + AuthContext, + InvalidBearerError, + Scope, + SubjectType, + check_workspace_membership, + get_authenticator, + set_auth_ctx, +) +from models import TenantStatus +from services.account_service import TenantService +from services.app_service import AppService + + +class BearerCheck: + """Resolve bearer → populate identity fields. Rate-limit is enforced + inside `BearerAuthenticator.authenticate`, so no separate step here. + Also publishes the resolved `AuthContext` via + :func:`libs.oauth_bearer.set_auth_ctx` — same shape the decorator-level + ``validate_bearer`` writes — so the surface gate + downstream readers + don't see two different identity sources. The reset token is parked on + ``ctx.auth_ctx_reset_token`` for `Pipeline.guard` to consume.""" + + def __call__(self, ctx: Context) -> None: + if not ctx.bearer_token: + raise Unauthorized("bearer required") + + try: + authn = get_authenticator().authenticate(ctx.bearer_token) + except InvalidBearerError as e: + raise Unauthorized(str(e)) + + ctx.subject_type = authn.subject_type + ctx.subject_email = authn.subject_email + ctx.subject_issuer = authn.subject_issuer + ctx.account_id = authn.account_id + ctx.scopes = frozenset(authn.scopes) + ctx.source = authn.source + ctx.token_id = authn.token_id + ctx.expires_at = authn.expires_at + ctx.token_hash = authn.token_hash + ctx.cached_verified_tenants = dict(authn.verified_tenants) + ctx.auth_ctx_reset_token = set_auth_ctx(authn) + + +class ScopeCheck: + """Verify ctx.scopes (already populated by BearerCheck) covers required.""" + + def __call__(self, ctx: Context) -> None: + if Scope.FULL in ctx.scopes or ctx.required_scope in ctx.scopes: + return + raise Forbidden("insufficient_scope") + + +class SurfaceCheck: + """Reject the request if the resolved subject is not in `accepted`.""" + + def __init__(self, *, accepted: frozenset[SubjectType]) -> None: + self._accepted = accepted + + def __call__(self, ctx: Context) -> None: + check_surface(self._accepted) + + +class AppResolver: + """Read ``app_id`` from ``ctx.path_params``; populate ctx.app + ctx.tenant. + + Every endpoint using the OAuth bearer pipeline must declare + ```` in its route — that is the design lock-in (no body / + header coupling). ``Pipeline.guard`` lifts ``request.view_args`` into + ``ctx.path_params`` at the boundary so this step doesn't need to know + about the request object. + """ + + def __call__(self, ctx: Context) -> None: + app_id = ctx.path_params.get("app_id") + if not app_id: + raise BadRequest("app_id is required in path") + app = AppService.get_app_by_id(db.session, app_id) + if not app or app.status != "normal": + raise NotFound("app not found") + if not app.enable_api: + raise Forbidden("service_api_disabled") + tenant = TenantService.get_tenant_by_id(db.session, str(app.tenant_id)) + if tenant is None or tenant.status == TenantStatus.ARCHIVE: + raise Forbidden("workspace unavailable") + ctx.app, ctx.tenant = app, tenant + + +class WorkspaceMembershipCheck: + """Layer 0 — workspace membership gate. + + CE-only (skipped when ENTERPRISE_ENABLED). Account-subject bearers + (dfoa_) only — SSO subjects skip. + """ + + def __call__(self, ctx: Context) -> None: + if dify_config.ENTERPRISE_ENABLED: + return + if ctx.subject_type != SubjectType.ACCOUNT: + return + if ctx.account_id is None or ctx.tenant is None: + raise Unauthorized("account_id or tenant unset — BearerCheck or AppResolver did not run") + if ctx.token_hash is None: + raise Unauthorized("token_hash unset — BearerCheck did not run") + + check_workspace_membership( + account_id=ctx.account_id, + tenant_id=ctx.must_tenant.id, + token_hash=ctx.token_hash, + cached_verdicts=ctx.cached_verified_tenants or {}, + ) + + +class AppAuthzCheck: + def __init__(self, resolve_strategy: Callable[[], AppAuthzStrategy]) -> None: + self._resolve = resolve_strategy + + def __call__(self, ctx: Context) -> None: + if not self._resolve().authorize(ctx): + raise Forbidden("subject_no_app_access") + + +class CallerMount: + def __init__(self, *mounters: CallerMounter) -> None: + self._mounters = mounters + + def __call__(self, ctx: Context) -> None: + if ctx.subject_type is None: + raise Unauthorized("subject_type unset — BearerCheck did not run") + for m in self._mounters: + if m.applies_to(ctx.must_subject_type): + m.mount(ctx) + return + raise Unauthorized("no caller mounter for subject type") + + +__all__ = [ + "AppAuthzCheck", + "AppResolver", + "AuthContext", + "BearerCheck", + "CallerMount", + "ScopeCheck", + "SurfaceCheck", + "WorkspaceMembershipCheck", +] diff --git a/api/controllers/openapi/auth/strategies.py b/api/controllers/openapi/auth/strategies.py new file mode 100644 index 0000000000..aaaaadd948 --- /dev/null +++ b/api/controllers/openapi/auth/strategies.py @@ -0,0 +1,168 @@ +"""Strategy classes for the openapi auth pipeline. + +App authorization (Acl/Membership) and caller mounting (Account/EndUser) +vary along independent axes; each strategy is one class so the pipeline +composition stays a flat list. +""" + +from __future__ import annotations + +from typing import Protocol + +from flask import current_app +from flask_login import user_logged_in + +from controllers.openapi.auth.context import Context +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from libs.oauth_bearer import SubjectType +from services.account_service import AccountService, TenantService +from services.end_user_service import EndUserService +from services.enterprise.enterprise_service import ( + EnterpriseService, + WebAppAccessMode, +) + + +class AppAuthzStrategy(Protocol): + def authorize(self, ctx: Context) -> bool: ... + + +class AclStrategy: + """Per-app ACL, evaluated in two stages. + + The EE gateway has already enforced tenancy and workspace membership + by the time this strategy runs, so AclStrategy only owns per-app ACL: + + 1. Subject vs access-mode compatibility (pure rule table). External-SSO + bearers belong to public-facing apps only; account bearers cover the + full set. A mismatch is an immediate deny — no IO. + 2. For modes that pair with the subject, decide whether the inner + permission API must run. Only `PRIVATE` (per-app selected-user list) + requires it; the remaining modes are pass-through. + """ + + _ALLOWED_MODES_BY_SUBJECT: dict[SubjectType, frozenset[WebAppAccessMode]] = { + SubjectType.ACCOUNT: frozenset( + { + WebAppAccessMode.PUBLIC, + WebAppAccessMode.SSO_VERIFIED, + WebAppAccessMode.PRIVATE_ALL, + WebAppAccessMode.PRIVATE, + } + ), + SubjectType.EXTERNAL_SSO: frozenset( + { + WebAppAccessMode.PUBLIC, + WebAppAccessMode.SSO_VERIFIED, + } + ), + } + + _MODES_REQUIRING_INNER_CHECK: frozenset[WebAppAccessMode] = frozenset({WebAppAccessMode.PRIVATE}) + + def authorize(self, ctx: Context) -> bool: + if ctx.app is None: + return False + access_mode = self._fetch_access_mode(ctx.app.id) + if access_mode is None: + return False + if not self._subject_allowed_for_mode(ctx.must_subject_type, access_mode): + return False + if access_mode not in self._MODES_REQUIRING_INNER_CHECK: + return True + return self._inner_permission_check(ctx) + + @staticmethod + def _fetch_access_mode(app_id: str) -> WebAppAccessMode | None: + settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id) + if settings is None: + return None + try: + return WebAppAccessMode(settings.access_mode) + except ValueError: + return None + + @classmethod + def _subject_allowed_for_mode(cls, subject_type: SubjectType, access_mode: WebAppAccessMode) -> bool: + return access_mode in cls._ALLOWED_MODES_BY_SUBJECT.get(subject_type, frozenset()) + + def _inner_permission_check(self, ctx: Context) -> bool: + if ctx.app is None: + return False + user_id = self._resolve_user_id(ctx) + if user_id is None: + return False + return EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp( + user_id=user_id, + app_id=ctx.app.id, + ) + + @staticmethod + def _resolve_user_id(ctx: Context) -> str | None: + if ctx.subject_type == SubjectType.ACCOUNT: + return str(ctx.account_id) if ctx.account_id is not None else None + if ctx.subject_email is None: + return None + account = AccountService.get_account_by_email(db.session, ctx.subject_email) + return str(account.id) if account is not None else None + + +class MembershipStrategy: + """Tenant-membership fallback. + + Used when webapp-auth is disabled (CE deployment). Account-bearing + subjects pass if they have a TenantAccountJoin row; EXTERNAL_SSO is + denied (it requires the webapp-auth surface). + """ + + def authorize(self, ctx: Context) -> bool: + if ctx.subject_type == SubjectType.EXTERNAL_SSO: + return False + if ctx.tenant is None: + return False + return TenantService.account_belongs_to_tenant(db.session, ctx.account_id, ctx.tenant.id) + + +def _login_as(user) -> None: + """Set Flask-Login request user so downstream services see the caller.""" + current_app.login_manager._update_request_context_with_user(user) # type:ignore + user_logged_in.send(current_app._get_current_object(), user=user) # type:ignore + + +class CallerMounter(Protocol): + def applies_to(self, subject_type: SubjectType) -> bool: ... + + def mount(self, ctx: Context) -> None: ... + + +class AccountMounter: + def applies_to(self, subject_type: SubjectType) -> bool: + return subject_type == SubjectType.ACCOUNT + + def mount(self, ctx: Context) -> None: + if ctx.account_id is None: + raise RuntimeError("AccountMounter: account_id unset — BearerCheck did not run") + account = AccountService.get_account_by_id(db.session, str(ctx.account_id)) + if account is None: + raise RuntimeError("AccountMounter: account row missing for resolved bearer") + account.current_tenant = ctx.must_tenant + _login_as(account) + ctx.caller, ctx.caller_kind = account, "account" + + +class EndUserMounter: + def applies_to(self, subject_type: SubjectType) -> bool: + return subject_type == SubjectType.EXTERNAL_SSO + + def mount(self, ctx: Context) -> None: + if ctx.tenant is None or ctx.app is None or ctx.subject_email is None: + raise RuntimeError("EndUserMounter: tenant/app/subject_email unset — earlier steps did not run") + end_user = EndUserService.get_or_create_end_user_by_type( + InvokeFrom.OPENAPI, + tenant_id=ctx.tenant.id, + app_id=ctx.app.id, + user_id=ctx.subject_email, + ) + _login_as(end_user) + ctx.caller, ctx.caller_kind = end_user, "end_user" diff --git a/api/controllers/openapi/auth/surface_gate.py b/api/controllers/openapi/auth/surface_gate.py new file mode 100644 index 0000000000..49485fb28c --- /dev/null +++ b/api/controllers/openapi/auth/surface_gate.py @@ -0,0 +1,89 @@ +"""Surface gate. + +`@accept_subjects(...)` is the route-level form. `SurfaceCheck` (pipeline +step) is the pipeline-level form. Both delegate to `check_surface` so the +audit emit + canonical-path message are single-sourced. + +Subjects come from `libs.oauth_bearer.SubjectType` directly — no parallel +vocabulary. Caller hits the wrong surface → 403 ``wrong_surface`` + audit +``openapi.wrong_surface_denied``. +""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import TypeVar + +from flask import request +from werkzeug.exceptions import Forbidden + +from controllers.openapi._audit import emit_wrong_surface +from libs.oauth_bearer import SubjectType, try_get_auth_ctx + +_CANONICAL_PATH: dict[SubjectType, str] = { + SubjectType.ACCOUNT: "/openapi/v1/apps", + SubjectType.EXTERNAL_SSO: "/openapi/v1/permitted-external-apps", +} + +F = TypeVar("F", bound=Callable[..., object]) + + +def check_surface(accepted: frozenset[SubjectType]) -> None: + """Enforce that the resolved subject is in ``accepted``. + + Reads the openapi auth ContextVar via :func:`try_get_auth_ctx`. Raises + ``Forbidden`` with ``wrong_surface`` + canonical-path hint on miss; + emits ``openapi.wrong_surface_denied`` audit. If no auth context is + set the bearer layer didn't run — that's a wiring bug, not a + user-driven failure, so surface it as a ``RuntimeError`` instead of + a silent 403. + """ + ctx = try_get_auth_ctx() + if ctx is None: + raise RuntimeError( + "check_surface called without an auth context; stack validate_bearer or BearerCheck above the surface gate" + ) + + subject = _coerce_subject_type(getattr(ctx, "subject_type", None)) + if subject in accepted: + return + + canonical = _CANONICAL_PATH.get(subject, "/openapi/v1/") if subject else "/openapi/v1/" + emit_wrong_surface( + subject_type=subject.value if subject else None, + attempted_path=request.path, + client_id=getattr(ctx, "client_id", None), + token_id=_stringify(getattr(ctx, "token_id", None)), + ) + raise Forbidden(description=f"wrong_surface (canonical: {canonical})") + + +def accept_subjects(*accepted: SubjectType) -> Callable[[F], F]: + accepted_set: frozenset[SubjectType] = frozenset(accepted) + + def deco(fn: F) -> F: + @wraps(fn) + def wrapper(*args: object, **kwargs: object) -> object: + check_surface(accepted_set) + return fn(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return deco + + +def _coerce_subject_type(raw: object) -> SubjectType | None: + if raw is None: + return None + if isinstance(raw, SubjectType): + return raw + if isinstance(raw, str): + return SubjectType(raw) + return None + + +def _stringify(value: object) -> str | None: + if value is None: + return None + return str(value) diff --git a/api/controllers/openapi/files.py b/api/controllers/openapi/files.py new file mode 100644 index 0000000000..eb16015821 --- /dev/null +++ b/api/controllers/openapi/files.py @@ -0,0 +1,72 @@ +"""POST /openapi/v1/apps//files/upload — upload a file for use in app inputs.""" + +from __future__ import annotations + +from flask import request +from flask_restx import Resource +from flask_restx.api import HTTPStatus +from werkzeug.exceptions import BadRequest + +import services +from controllers.common.errors import ( + BlockedFileExtensionError, + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.openapi import openapi_ns +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE +from extensions.ext_database import db +from fields.file_fields import FileResponse +from libs.oauth_bearer import Scope +from models import Account, App +from services.file_service import FileService + + +@openapi_ns.route("/apps//files/upload") +class AppFileUploadApi(Resource): + @openapi_ns.doc("upload_file_for_app_input") + @openapi_ns.doc(description="Upload a file to use as an input variable when running the app") + @openapi_ns.doc( + responses={ + 201: "File uploaded successfully", + 400: "Bad request — no file or filename missing", + 401: "Unauthorized — invalid or expired bearer token", + 413: "File too large", + 415: "Unsupported file type or blocked extension", + } + ) + @openapi_ns.response(HTTPStatus.CREATED, "File uploaded", openapi_ns.models[FileResponse.__name__]) + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def post(self, app_id: str, app_model: App, caller: Account, caller_kind: str): + if "file" not in request.files: + raise NoFileUploadedError() + if len(request.files) > 1: + raise TooManyFilesError() + + file = request.files["file"] + if not file.mimetype: + raise UnsupportedFileTypeError() + if not file.filename: + raise FilenameNotExistsError() + + try: + upload_file = FileService(db.engine).upload_file( + filename=file.filename, + content=file.stream.read(), + mimetype=file.mimetype, + user=caller, + ) + except ValueError as exc: + raise BadRequest(str(exc)) + except services.errors.file.FileTooLargeError as exc: + raise FileTooLargeError(exc.description) + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError(exc.description) + + response = FileResponse.model_validate(upload_file, from_attributes=True) + return response.model_dump(mode="json"), 201 diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py new file mode 100644 index 0000000000..7d54140efd --- /dev/null +++ b/api/controllers/openapi/human_input_form.py @@ -0,0 +1,107 @@ +""" +OpenAPI bearer-authed human input form endpoints. + +GET /apps//form/human_input/ — fetch paused form definition +POST /apps//form/human_input/ — submit form response +""" + +from __future__ import annotations + +import json +import logging + +from flask import Response, request +from flask_restx import Resource +from werkzeug.exceptions import BadRequest, NotFound + +from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values +from controllers.common.schema import register_schema_models +from controllers.openapi import openapi_ns +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from extensions.ext_database import db +from libs.helper import to_timestamp +from libs.oauth_bearer import Scope +from models.model import App +from services.human_input_service import FormNotFoundError, HumanInputService + +logger = logging.getLogger(__name__) + +register_schema_models(openapi_ns, HumanInputFormSubmitPayload) + + +def _jsonify_form_definition(form) -> Response: + definition_payload = form.get_definition().model_dump() + payload = { + "form_content": definition_payload["rendered_content"], + "inputs": definition_payload["inputs"], + "resolved_default_values": stringify_form_default_values(definition_payload["default_values"]), + "user_actions": definition_payload["user_actions"], + "expiration_time": to_timestamp(form.expiration_time), + } + return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") + + +def _ensure_form_belongs_to_app(form, app_model: App) -> None: + if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id: + raise NotFound("Form not found") + + +def _ensure_form_is_allowed_for_openapi(form) -> None: + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI): + raise NotFound("Form not found") + + +@openapi_ns.route("/apps//form/human_input/") +class OpenApiWorkflowHumanInputFormApi(Resource): + @openapi_ns.response(200, "Form definition") + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def get(self, app_id: str, form_token: str, app_model: App, caller, caller_kind: str): + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_openapi(form) + service.ensure_form_active(form) + return _jsonify_form_definition(form) + + @openapi_ns.expect(openapi_ns.models[HumanInputFormSubmitPayload.__name__]) + @openapi_ns.response(200, "Form submitted") + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def post(self, app_id: str, form_token: str, app_model: App, caller, caller_kind: str): + payload = HumanInputFormSubmitPayload.model_validate(request.get_json(silent=True) or {}) + + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_openapi(form) + + submission_user_id: str | None = None + submission_end_user_id: str | None = None + if caller_kind == "account": + submission_user_id = caller.id + else: + submission_end_user_id = caller.id + + if form.recipient_type is None: + logger.warning("Recipient type is None for form, form_token=%s", form_token) + raise BadRequest("Form recipient type is invalid") + + try: + service.submit_form_by_token( + recipient_type=form.recipient_type, + form_token=form_token, + selected_action_id=payload.action, + form_data=payload.inputs, + submission_user_id=submission_user_id, + submission_end_user_id=submission_end_user_id, + ) + except FormNotFoundError: + raise NotFound("Form not found") + + return {}, 200 diff --git a/api/controllers/openapi/index.py b/api/controllers/openapi/index.py new file mode 100644 index 0000000000..a6626f9cc6 --- /dev/null +++ b/api/controllers/openapi/index.py @@ -0,0 +1,9 @@ +from flask_restx import Resource + +from controllers.openapi import openapi_ns + + +@openapi_ns.route("/_health") +class HealthApi(Resource): + def get(self): + return {"ok": True} diff --git a/api/controllers/openapi/oauth_device.py b/api/controllers/openapi/oauth_device.py new file mode 100644 index 0000000000..bbee345767 --- /dev/null +++ b/api/controllers/openapi/oauth_device.py @@ -0,0 +1,398 @@ +"""Device-flow endpoints under /openapi/v1/oauth/device/*. Two +sub-groups in one module: + + Protocol (RFC 8628, public + rate-limited): + POST /oauth/device/code + POST /oauth/device/token + GET /oauth/device/lookup + + Approval (account branch, console-cookie authed): + POST /oauth/device/approve + POST /oauth/device/deny + +SSO branch lives in oauth_device_sso.py. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from flask import request +from flask_login import login_required +from flask_restx import Resource +from pydantic import BaseModel, ValidationError +from werkzeug.exceptions import BadRequest + +from configs import dify_config +from controllers.common.schema import query_params_from_model +from controllers.console.wraps import account_initialization_required, setup_required +from controllers.openapi import openapi_ns +from controllers.openapi._models import ( + AccountPayload, + DeviceCodeRequest, + DeviceCodeResponse, + DeviceLookupQuery, + DeviceLookupResponse, + DeviceMutateRequest, + DeviceMutateResponse, + DevicePollRequest, + WorkspacePayload, +) +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs.helper import extract_remote_ip +from libs.login import current_account_with_tenant +from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType, bearer_feature_required +from libs.rate_limit import ( + LIMIT_APPROVE_CONSOLE, + LIMIT_DEVICE_CODE_PER_IP, + LIMIT_LOOKUP_PUBLIC, + rate_limit, +) +from services.account_service import TenantService +from services.oauth_device_flow import ( + ACCOUNT_ISSUER_SENTINEL, + DEFAULT_POLL_INTERVAL_SECONDS, + DEVICE_FLOW_TTL_SECONDS, + DeviceFlowRedis, + DeviceFlowStatus, + InvalidTransitionError, + PollPayload, + SlowDownDecision, + StateNotFoundError, + mint_oauth_token, + oauth_ttl_days, +) +from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy + +logger = logging.getLogger(__name__) + + +# ========================================================================= +# Validation helpers +# ========================================================================= + + +def _validate_json[M: BaseModel](model: type[M]) -> M: + body = request.get_json(silent=True) or {} + try: + return model.model_validate(body) + except ValidationError as exc: + raise BadRequest(str(exc)) + + +def _validate_query[M: BaseModel](model: type[M]) -> M: + try: + return model.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise BadRequest(str(exc)) + + +# ========================================================================= +# Protocol endpoints — RFC 8628 (public + per-IP rate limit) +# ========================================================================= + + +@openapi_ns.route("/oauth/device/code") +class OAuthDeviceCodeApi(Resource): + @openapi_ns.expect(openapi_ns.models[DeviceCodeRequest.__name__]) + @openapi_ns.response(200, "Device code created", openapi_ns.models[DeviceCodeResponse.__name__]) + @rate_limit(LIMIT_DEVICE_CODE_PER_IP) + def post(self): + payload = _validate_json(DeviceCodeRequest) + client_id = payload.client_id + device_label = payload.device_label + + if client_id not in dify_config.OPENAPI_KNOWN_CLIENT_IDS: + return {"error": "unsupported_client"}, 400 + + store = DeviceFlowRedis(redis_client) + ip = extract_remote_ip(request) + device_code, user_code, expires_in = store.start(client_id, device_label, created_ip=ip) + + return { + "device_code": device_code, + "user_code": user_code, + "verification_uri": _verification_uri(), + "expires_in": expires_in, + "interval": DEFAULT_POLL_INTERVAL_SECONDS, + }, 200 + + +@openapi_ns.route("/oauth/device/token") +class OAuthDeviceTokenApi(Resource): + """RFC 8628 poll.""" + + @openapi_ns.expect(openapi_ns.models[DevicePollRequest.__name__]) + def post(self): + payload = _validate_json(DevicePollRequest) + device_code = payload.device_code + + store = DeviceFlowRedis(redis_client) + + # slow_down beats every other branch — polling-too-fast clients + # see only that response regardless of underlying state. + if store.record_poll(device_code, DEFAULT_POLL_INTERVAL_SECONDS) is SlowDownDecision.SLOW_DOWN: + return {"error": "slow_down"}, 400 + + state = store.load_by_device_code(device_code) + if state is None: + return {"error": "expired_token"}, 400 + + if state.status is DeviceFlowStatus.PENDING: + return {"error": "authorization_pending"}, 400 + + terminal = store.consume_on_poll(device_code) + if terminal is None: + return {"error": "expired_token"}, 400 + + if terminal.status is DeviceFlowStatus.DENIED: + return {"error": "access_denied"}, 400 + + poll_payload: PollPayload | dict[str, Any] = terminal.poll_payload or {} + if "token" not in poll_payload: + logger.error("device_flow: approved state missing poll_payload for %s", device_code) + return {"error": "expired_token"}, 400 + + _audit_cross_ip_if_needed(state) + return poll_payload, 200 + + +@openapi_ns.route("/oauth/device/lookup") +class OAuthDeviceLookupApi(Resource): + """Read-only — public for pre-validate before login. user_code is + high-entropy + short-TTL; per-IP rate limit blocks enumeration. + """ + + @openapi_ns.doc(params=query_params_from_model(DeviceLookupQuery)) + @openapi_ns.response(200, "Device lookup result", openapi_ns.models[DeviceLookupResponse.__name__]) + @rate_limit(LIMIT_LOOKUP_PUBLIC) + def get(self): + payload = _validate_query(DeviceLookupQuery) + user_code = payload.user_code.strip().upper() + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + return {"valid": False, "expires_in_remaining": 0, "client_id": None}, 200 + + _device_code, state = found + if state.status is not DeviceFlowStatus.PENDING: + return {"valid": False, "expires_in_remaining": 0, "client_id": state.client_id}, 200 + + return { + "valid": True, + "expires_in_remaining": DEVICE_FLOW_TTL_SECONDS, + "client_id": state.client_id, + }, 200 + + +# ========================================================================= +# Approval endpoints — account branch (cookie-authed) +# ========================================================================= + + +_APPROVE_GUARD_KEY_FMT = "device_code:{code}:approving" +_APPROVE_GUARD_TTL_SECONDS = 10 + + +@openapi_ns.route("/oauth/device/approve") +class DeviceApproveApi(Resource): + @openapi_ns.expect(openapi_ns.models[DeviceMutateRequest.__name__]) + @openapi_ns.response(200, "Approved", openapi_ns.models[DeviceMutateResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @bearer_feature_required + @rate_limit(LIMIT_APPROVE_CONSOLE) + def post(self): + payload = _validate_json(DeviceMutateRequest) + user_code = payload.user_code.strip().upper() + + account, tenant = current_account_with_tenant() + store = DeviceFlowRedis(redis_client) + + found = store.load_by_user_code(user_code) + if found is None: + return {"error": "expired_or_unknown"}, 404 + device_code, state = found + if state.status is not DeviceFlowStatus.PENDING: + return {"error": "already_resolved"}, 409 + + # SET NX guard — without it, two in-flight approves both pass + # PENDING, both mint, and the second upsert silently rotates the + # first caller into an already-revoked token. + guard_key = _APPROVE_GUARD_KEY_FMT.format(code=device_code) + if not redis_client.set(guard_key, "1", nx=True, ex=_APPROVE_GUARD_TTL_SECONDS): + return {"error": "approve_in_progress"}, 409 + + try: + profile = MINTABLE_PROFILES[SubjectType.ACCOUNT] + try: + validate_mint_policy( + subject_type=profile.subject_type, + prefix=profile.prefix, + scopes=profile.scopes, + ) + except MintPolicyViolation as e: + raise BadRequest(description=str(e)) from None + ttl_days = oauth_ttl_days(tenant_id=tenant) + mint = mint_oauth_token( + db.session, + redis_client, + subject_email=account.email, + subject_issuer=ACCOUNT_ISSUER_SENTINEL, + account_id=str(account.id), + client_id=state.client_id, + device_label=state.device_label, + prefix=profile.prefix, + ttl_days=ttl_days, + ) + + poll_payload = _build_account_poll_payload(account, tenant, mint) + try: + store.approve( + device_code, + subject_email=account.email, + account_id=str(account.id), + subject_issuer=ACCOUNT_ISSUER_SENTINEL, + minted_token=mint.token, + token_id=str(mint.token_id), + poll_payload=poll_payload, + ) + except (StateNotFoundError, InvalidTransitionError): + # Row minted but state vanished — roll forward; the orphan + # token is revocable via auth devices list / Authorized Apps. + logger.exception("device_flow: approve raced on %s", device_code) + return {"error": "state_lost"}, 409 + finally: + redis_client.delete(guard_key) + + _emit_approve_audit(state, account, tenant, mint) + return {"status": "approved"}, 200 + + +@openapi_ns.route("/oauth/device/deny") +class DeviceDenyApi(Resource): + @openapi_ns.expect(openapi_ns.models[DeviceMutateRequest.__name__]) + @openapi_ns.response(200, "Denied", openapi_ns.models[DeviceMutateResponse.__name__]) + @setup_required + @login_required + @account_initialization_required + @bearer_feature_required + @rate_limit(LIMIT_APPROVE_CONSOLE) + def post(self): + payload = _validate_json(DeviceMutateRequest) + user_code = payload.user_code.strip().upper() + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + return {"error": "expired_or_unknown"}, 404 + device_code, state = found + if state.status is not DeviceFlowStatus.PENDING: + return {"error": "already_resolved"}, 409 + + try: + store.deny(device_code) + except (StateNotFoundError, InvalidTransitionError): + logger.exception("device_flow: deny raced on %s", device_code) + return {"error": "state_lost"}, 409 + + _emit_deny_audit(state) + return {"status": "denied"}, 200 + + +# ========================================================================= +# Helpers +# ========================================================================= + + +def _verification_uri() -> str: + base = getattr(dify_config, "CONSOLE_WEB_URL", None) + if base: + return f"{base.rstrip('/')}/device" + return f"{request.host_url.rstrip('/')}/device" + + +def _audit_cross_ip_if_needed(state) -> None: + poll_ip = extract_remote_ip(request) + if state.created_ip and poll_ip and poll_ip != state.created_ip: + logger.warning( + "audit: oauth.device_code_cross_ip_poll token_id=%s creation_ip=%s poll_ip=%s", + state.token_id, + state.created_ip, + poll_ip, + extra={ + "audit": True, + "token_id": state.token_id, + "creation_ip": state.created_ip, + "poll_ip": poll_ip, + }, + ) + + +def _build_account_poll_payload(account, tenant, mint) -> PollPayload: + rows = TenantService.get_workspaces_for_account(db.session, str(account.id)) + workspaces = [WorkspacePayload(id=str(t.id), name=t.name, role=getattr(m, "role", "")) for t, m in rows] + # Prefer active session tenant → DB-flagged current join → first membership. + default_ws_id = None + if tenant and any(w.id == str(tenant) for w in workspaces): + default_ws_id = str(tenant) + if default_ws_id is None: + for _t, m in rows: + if getattr(m, "current", False): + default_ws_id = str(m.tenant_id) + break + if default_ws_id is None and workspaces: + default_ws_id = workspaces[0].id + + payload: PollPayload = { + "token": mint.token, + "expires_at": mint.expires_at.isoformat(), + "subject_type": SubjectType.ACCOUNT, + "account": AccountPayload(id=str(account.id), email=account.email, name=account.name).model_dump(mode="json"), + "workspaces": [w.model_dump(mode="json") for w in workspaces], + "default_workspace_id": default_ws_id, + "token_id": str(mint.token_id), + } + return payload + + +def _emit_approve_audit(state, account, tenant, mint) -> None: + logger.warning( + "audit: oauth.device_flow_approved token_id=%s subject=%s client_id=%s device_label=%s rotated=? expires_at=%s", + mint.token_id, + account.email, + state.client_id, + state.device_label, + mint.expires_at, + extra={ + "audit": True, + "event": "oauth.device_flow_approved", + "token_id": str(mint.token_id), + "subject_type": SubjectType.ACCOUNT, + "subject_email": account.email, + "account_id": str(account.id), + "tenant_id": tenant, + "client_id": state.client_id, + "device_label": state.device_label, + "scopes": ["full"], + "expires_at": mint.expires_at.isoformat(), + }, + ) + + +def _emit_deny_audit(state) -> None: + logger.warning( + "audit: oauth.device_flow_denied client_id=%s device_label=%s", + state.client_id, + state.device_label, + extra={ + "audit": True, + "event": "oauth.device_flow_denied", + "client_id": state.client_id, + "device_label": state.device_label, + }, + ) diff --git a/api/controllers/openapi/oauth_device_sso.py b/api/controllers/openapi/oauth_device_sso.py new file mode 100644 index 0000000000..0218d14330 --- /dev/null +++ b/api/controllers/openapi/oauth_device_sso.py @@ -0,0 +1,365 @@ +"""SSO-branch device-flow endpoints under /openapi/v1/oauth/device/*. +EE-only. Browser flow: + + GET /oauth/device/sso-initiate → 302 to IdP authorize URL + GET /oauth/device/sso-complete → ACS callback, sets approval-grant cookie + GET /oauth/device/approval-context → SPA reads cookie claims (idempotent) + POST /oauth/device/approve-external → mints dfoe_ token + clears cookie + +Function-based (raw @bp.route) rather than Resource classes because the +handlers do redirects + cookie kwargs that don't fit the Resource shape. +""" + +from __future__ import annotations + +import logging +import secrets +from dataclasses import dataclass + +from flask import jsonify, make_response, redirect, request +from pydantic import ValidationError +from werkzeug.exceptions import ( + BadGateway, + BadRequest, + Conflict, + Forbidden, + NotFound, + Unauthorized, +) + +from configs import dify_config +from controllers.openapi import bp +from controllers.openapi._models import ExtSubjectAssertionClaims +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs import jws +from libs.device_flow_security import ( + APPROVAL_GRANT_COOKIE_NAME, + ApprovalGrantClaims, + approval_grant_cleared_cookie_kwargs, + approval_grant_cookie_kwargs, + consume_approval_grant_nonce, + consume_sso_assertion_nonce, + enterprise_only, + mint_approval_grant, + verify_approval_grant, +) +from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType +from libs.rate_limit import ( + LIMIT_APPROVE_EXT_PER_EMAIL, + LIMIT_SSO_INITIATE_PER_IP, + enforce, + rate_limit, +) +from services.account_service import AccountService +from services.enterprise.enterprise_service import EnterpriseService +from services.oauth_device_flow import ( + DeviceFlowRedis, + DeviceFlowStatus, + InvalidTransitionError, + PollPayload, + StateNotFoundError, + mint_oauth_token, + oauth_ttl_days, +) +from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy + +logger = logging.getLogger(__name__) + + +# Matches DEVICE_FLOW_TTL_SECONDS so the signed state can't outlive the +# device_code it references. +STATE_ENVELOPE_TTL_SECONDS = 15 * 60 + +# Canonical sso-complete path. IdP-side ACS callback URL must point here. +_SSO_COMPLETE_PATH = "/openapi/v1/oauth/device/sso-complete" + + +def _trusted_origin() -> str: + base = (dify_config.CONSOLE_API_URL or "").rstrip("/") + if not base: + raise BadGateway("console_api_url_unset") + return base + + +@bp.route("/oauth/device/sso-initiate", methods=["GET"]) +@enterprise_only +@rate_limit(LIMIT_SSO_INITIATE_PER_IP) +def sso_initiate(): + user_code = (request.args.get("user_code") or "").strip().upper() + if not user_code: + raise BadRequest("user_code required") + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + raise BadRequest("invalid_user_code") + _, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise BadRequest("invalid_user_code") + + origin = _trusted_origin() + keyset = jws.KeySet.from_shared_secret() + signed_state = jws.sign( + keyset, + payload={ + "redirect_url": "", + "app_code": "", + "intent": "device_flow", + "user_code": user_code, + "nonce": secrets.token_urlsafe(16), + "return_to": "", + "idp_callback_url": f"{origin}{_SSO_COMPLETE_PATH}", + }, + aud=jws.AUD_STATE_ENVELOPE, + ttl_seconds=STATE_ENVELOPE_TTL_SECONDS, + ) + + try: + reply = EnterpriseService.initiate_device_flow_sso(signed_state) + except Exception as e: + logger.warning("sso-initiate: enterprise call failed: %s", e) + raise BadGateway("sso_initiate_failed") from e + + url = (reply or {}).get("url") + if not url: + raise BadGateway("sso_initiate_missing_url") + + # Clear stale approval-grant — defends against cross-tab/back-button mixing. + resp = redirect(url, code=302) + resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) + return resp + + +@bp.route("/oauth/device/sso-complete", methods=["GET"]) +@enterprise_only +def sso_complete(): + blob = request.args.get("sso_assertion") + if not blob: + raise BadRequest("sso_assertion required") + + keyset = jws.KeySet.from_shared_secret() + + try: + raw_claims = jws.verify(keyset, blob, expected_aud=jws.AUD_EXT_SUBJECT_ASSERTION) + except jws.VerifyError as e: + logger.warning("sso-complete: rejected assertion: %s", e) + raise BadRequest("invalid_sso_assertion") from e + + try: + claims = ExtSubjectAssertionClaims.model_validate(raw_claims) + except ValidationError as e: + logger.warning("sso-complete: claim shape invalid: %s", e) + raise BadRequest("invalid_sso_assertion") from e + + if not consume_sso_assertion_nonce(redis_client, claims.nonce): + raise BadRequest("invalid_sso_assertion") + + user_code = claims.user_code.strip().upper() + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(user_code) + if found is None: + raise Conflict("user_code_not_pending") + _, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise Conflict("user_code_not_pending") + + if AccountService.has_active_account_with_email(db.session, claims.email): + _emit_external_rejection_audit( + state, + _RejectedClaims(subject_email=claims.email, subject_issuer=claims.issuer), + reason="email_belongs_to_dify_account", + ) + return redirect("/device?sso_error=email_belongs_to_dify_account", code=302) + + iss = _trusted_origin() + cookie_value, _ = mint_approval_grant( + keyset=keyset, + iss=iss, + subject_email=claims.email, + subject_issuer=claims.issuer, + user_code=user_code, + ) + + resp = redirect("/device?sso_verified=1", code=302) + resp.set_cookie(**approval_grant_cookie_kwargs(cookie_value)) + return resp + + +@bp.route("/oauth/device/approval-context", methods=["GET"]) +@enterprise_only +def approval_context(): + token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) + if not token: + raise Unauthorized("no_session") + + keyset = jws.KeySet.from_shared_secret() + try: + claims = verify_approval_grant(keyset, token) + except jws.VerifyError as e: + logger.warning("approval-context: bad cookie: %s", e) + raise Unauthorized("no_session") from e + + return jsonify( + { + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "user_code": claims.user_code, + "csrf_token": claims.csrf_token, + "expires_at": claims.expires_at.isoformat(), + } + ), 200 + + +@bp.route("/oauth/device/approve-external", methods=["POST"]) +@enterprise_only +def approve_external(): + token = request.cookies.get(APPROVAL_GRANT_COOKIE_NAME) + if not token: + raise Unauthorized("invalid_session") + + keyset = jws.KeySet.from_shared_secret() + try: + claims: ApprovalGrantClaims = verify_approval_grant(keyset, token) + except jws.VerifyError as e: + logger.warning("approve-external: bad cookie: %s", e) + raise Unauthorized("invalid_session") from e + + enforce(LIMIT_APPROVE_EXT_PER_EMAIL, key=f"subject:{claims.subject_email}") + + csrf_header = request.headers.get("X-CSRF-Token", "") + if not csrf_header or not secrets.compare_digest(csrf_header, claims.csrf_token): + raise Forbidden("csrf_mismatch") + + data = request.get_json(silent=True) or {} + body_user_code = (data.get("user_code") or "").strip().upper() + if body_user_code != claims.user_code: + raise BadRequest("user_code_mismatch") + + store = DeviceFlowRedis(redis_client) + found = store.load_by_user_code(claims.user_code) + if found is None: + raise NotFound("user_code_not_pending") + device_code, state = found + if state.status is not DeviceFlowStatus.PENDING: + raise Conflict("user_code_not_pending") + + if AccountService.has_active_account_with_email(db.session, claims.subject_email): + _emit_external_rejection_audit(state, claims, reason="email_belongs_to_dify_account") + raise Forbidden("email_belongs_to_dify_account") + + if not consume_approval_grant_nonce(redis_client, claims.nonce): + raise Unauthorized("session_already_consumed") + + profile = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] + try: + validate_mint_policy( + subject_type=profile.subject_type, + prefix=profile.prefix, + scopes=profile.scopes, + ) + except MintPolicyViolation as e: + raise BadRequest(description=str(e)) from None + + ttl_days = oauth_ttl_days(tenant_id=None) + mint = mint_oauth_token( + db.session, + redis_client, + subject_email=claims.subject_email, + subject_issuer=claims.subject_issuer, + account_id=None, + client_id=state.client_id, + device_label=state.device_label, + prefix=profile.prefix, + ttl_days=ttl_days, + ) + + # SSO branch of the shared PollPayload contract: account/workspace + # fields are zero-filled (`None` / `[]`) for parity with the account + # branch in `oauth_device._build_account_poll_payload`. + poll_payload: PollPayload = { + "token": mint.token, + "expires_at": mint.expires_at.isoformat(), + "subject_type": SubjectType.EXTERNAL_SSO, + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "account": None, + "workspaces": [], + "default_workspace_id": None, + "token_id": str(mint.token_id), + } + + try: + store.approve( + device_code, + subject_email=claims.subject_email, + account_id=None, + subject_issuer=claims.subject_issuer, + minted_token=mint.token, + token_id=str(mint.token_id), + poll_payload=poll_payload, + ) + except (StateNotFoundError, InvalidTransitionError) as e: + logger.exception("approve-external: state transition raced") + raise Conflict("state_lost") from e + + _emit_approve_external_audit(state, claims, mint) + + resp = make_response(jsonify({"status": "approved"}), 200) + resp.set_cookie(**approval_grant_cleared_cookie_kwargs()) + return resp + + +@dataclass(frozen=True) +class _RejectedClaims: + """Minimal subject shape consumed by `_emit_external_rejection_audit`. + + Mirrors the attributes used from `ApprovalGrantClaims` so callers holding + only a raw JWS claims dict (e.g. `sso_complete`) can emit the same audit + event without reaching for the full dataclass. + """ + + subject_email: str + subject_issuer: str + + +def _emit_external_rejection_audit(state, claims, *, reason: str) -> None: + logger.warning( + "audit: oauth.device_flow_rejected subject_type=%s subject_email=%s subject_issuer=%s reason=%s", + SubjectType.EXTERNAL_SSO, + claims.subject_email, + claims.subject_issuer, + reason, + extra={ + "audit": True, + "event": "oauth.device_flow_rejected", + "subject_type": SubjectType.EXTERNAL_SSO, + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "reason": reason, + "client_id": state.client_id, + "device_label": state.device_label, + }, + ) + + +def _emit_approve_external_audit(state, claims, mint) -> None: + logger.warning( + "audit: oauth.device_flow_approved subject_type=%s subject_email=%s subject_issuer=%s token_id=%s", + SubjectType.EXTERNAL_SSO, + claims.subject_email, + claims.subject_issuer, + mint.token_id, + extra={ + "audit": True, + "event": "oauth.device_flow_approved", + "subject_type": SubjectType.EXTERNAL_SSO, + "subject_email": claims.subject_email, + "subject_issuer": claims.subject_issuer, + "token_id": str(mint.token_id), + "client_id": state.client_id, + "device_label": state.device_label, + "scopes": ["apps:run"], + "expires_at": mint.expires_at.isoformat(), + }, + ) diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py new file mode 100644 index 0000000000..b14b2d400f --- /dev/null +++ b/api/controllers/openapi/workflow_events.py @@ -0,0 +1,119 @@ +""" +OpenAPI bearer-authed workflow reconnect event stream endpoint. + +GET /apps//tasks//events + — reconnect to the SSE stream for a paused/running workflow run. + `task_id` is treated as `workflow_run_id`. +""" + +from __future__ import annotations + +import json +from collections.abc import Generator + +from flask import Response, request +from flask_restx import Resource +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import NotFound, UnprocessableEntity + +from controllers.openapi import openapi_ns +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE +from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator +from core.app.apps.base_app_generator import BaseAppGenerator +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.apps.message_generator import MessageGenerator +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.app.entities.task_entities import StreamEvent +from core.workflow.human_input_policy import HumanInputSurface +from extensions.ext_database import db +from libs.oauth_bearer import Scope +from models.enums import CreatorUserRole +from models.model import App, AppMode +from repositories.factory import DifyAPIRepositoryFactory +from services.workflow_event_snapshot_service import build_workflow_event_stream + + +@openapi_ns.route("/apps//tasks//events") +class OpenApiWorkflowEventsApi(Resource): + @openapi_ns.response(200, "SSE event stream") + @OAUTH_BEARER_PIPELINE.guard(scope=Scope.APPS_RUN) + def get(self, app_id: str, task_id: str, app_model: App, caller, caller_kind: str): + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: + raise UnprocessableEntity("mode_not_supported_for_event_reconnect") + + session_maker = sessionmaker(db.engine) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + workflow_run = repo.get_workflow_run_by_id_and_tenant_id( + tenant_id=app_model.tenant_id, + run_id=task_id, + ) + + if workflow_run is None: + raise NotFound("Workflow run not found") + + if workflow_run.app_id != app_model.id: + raise NotFound("Workflow run not found") + + if caller_kind == "account": + if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id: + raise NotFound("Workflow run not found") + else: + if workflow_run.created_by_role != CreatorUserRole.END_USER or workflow_run.created_by != caller.id: + raise NotFound("Workflow run not found") + + workflow_run_entity = workflow_run + + if workflow_run_entity.finished_at is not None: + response = WorkflowResponseConverter.workflow_run_result_to_finish_response( + task_id=workflow_run_entity.id, + workflow_run=workflow_run_entity, + creator_user=caller, + ) + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + + def _generate_finished_events() -> Generator[str, None, None]: + yield f"data: {json.dumps(payload)}\n\n" + + event_generator = _generate_finished_events + else: + msg_generator = MessageGenerator() + generator: BaseAppGenerator + if app_mode == AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + else: + generator = WorkflowAppGenerator() + + include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" + continue_on_pause = request.args.get("continue_on_pause", "false").lower() == "true" + terminal_events: list[StreamEvent] | None = [] if continue_on_pause else None + + def _generate_stream_events(): + if include_state_snapshot: + return generator.convert_to_event_stream( + build_workflow_event_stream( + app_mode=app_mode, + workflow_run=workflow_run_entity, + tenant_id=app_model.tenant_id, + app_id=app_model.id, + session_maker=session_maker, + human_input_surface=HumanInputSurface.OPENAPI, + close_on_pause=not continue_on_pause, + ) + ) + return generator.convert_to_event_stream( + msg_generator.retrieve_events( + app_mode, + workflow_run_entity.id, + terminal_events=terminal_events, + ), + ) + + event_generator = _generate_stream_events + + return Response( + event_generator(), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) diff --git a/api/controllers/openapi/workspaces.py b/api/controllers/openapi/workspaces.py new file mode 100644 index 0000000000..5fc1e1178d --- /dev/null +++ b/api/controllers/openapi/workspaces.py @@ -0,0 +1,78 @@ +"""User-scoped workspace reads under /openapi/v1/workspaces. Bearer-authed +counterparts to the cookie-authed /console/api/workspaces endpoints. + +Account bearers (dfoa_) see every tenant they're a member of. External +SSO bearers (dfoe_) have no account_id and so see an empty list — that +matches /openapi/v1/account. +""" + +from __future__ import annotations + +from itertools import starmap + +from flask_restx import Resource +from werkzeug.exceptions import NotFound + +from controllers.openapi import openapi_ns +from controllers.openapi._models import WorkspaceDetailResponse, WorkspaceListResponse, WorkspaceSummaryResponse +from controllers.openapi.auth.surface_gate import accept_subjects +from extensions.ext_database import db +from libs.oauth_bearer import ( + ACCEPT_USER_ANY, + SubjectType, + get_auth_ctx, + validate_bearer, +) +from models import Tenant, TenantAccountJoin +from services.account_service import TenantService + + +@openapi_ns.route("/workspaces") +class WorkspacesApi(Resource): + @openapi_ns.response(200, "Workspace list", openapi_ns.models[WorkspaceListResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + @accept_subjects(SubjectType.ACCOUNT) + def get(self): + ctx = get_auth_ctx() + + rows = TenantService.get_workspaces_for_account(db.session, str(ctx.account_id)) + + return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows))).model_dump(mode="json"), 200 + + +@openapi_ns.route("/workspaces/") +class WorkspaceByIdApi(Resource): + @openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__]) + @validate_bearer(accept=ACCEPT_USER_ANY) + @accept_subjects(SubjectType.ACCOUNT) + def get(self, workspace_id: str): + ctx = get_auth_ctx() + + row = TenantService.find_workspace_for_account(db.session, str(ctx.account_id), workspace_id) + # 404 (not 403) on non-member so workspace IDs don't leak across tenants. + if row is None: + raise NotFound("workspace not found") + + tenant, membership = row + return _workspace_detail(tenant, membership).model_dump(mode="json"), 200 + + +def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse: + return WorkspaceSummaryResponse( + id=str(tenant.id), + name=tenant.name, + role=getattr(membership, "role", ""), + status=tenant.status, + current=getattr(membership, "current", False), + ) + + +def _workspace_detail(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceDetailResponse: + return WorkspaceDetailResponse( + id=str(tenant.id), + name=tenant.name, + role=getattr(membership, "role", ""), + status=tenant.status, + current=getattr(membership, "current", False), + created_at=tenant.created_at.isoformat() if tenant.created_at else None, + ) diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py index 11650fa4b5..ccc9c0f8f6 100644 --- a/api/controllers/web/wraps.py +++ b/api/controllers/web/wraps.py @@ -16,7 +16,7 @@ from libs.passport import PassportService from libs.token import extract_webapp_passport from models.model import App, EndUser, Site from services.app_service import AppService -from services.enterprise.enterprise_service import EnterpriseService, WebAppSettings +from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService @@ -74,7 +74,7 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id) if not webapp_settings: raise NotFound("Web app settings not found.") - app_web_auth_enabled = webapp_settings.access_mode != "public" + app_web_auth_enabled = webapp_settings.access_mode != WebAppAccessMode.PUBLIC _validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled) _validate_user_accessibility( @@ -88,7 +88,8 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> raise Unauthorized("Please re-login to access the web app.") app_id = AppService.get_app_id_by_code(app_code) app_web_auth_enabled = ( - EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id).access_mode != "public" + EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id).access_mode + != WebAppAccessMode.PUBLIC ) if app_web_auth_enabled: raise WebAppAuthRequiredError() diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 571d5b66c0..ae11daebb6 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -198,7 +198,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=( + args.get("parent_message_id") + if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI} + else UUID_NIL + ), user_id=user.id, stream=streaming, invoke_from=invoke_from, diff --git a/api/core/app/apps/agent_chat/app_generator.py b/api/core/app/apps/agent_chat/app_generator.py index 5cdc477028..9df959e6f3 100644 --- a/api/core/app/apps/agent_chat/app_generator.py +++ b/api/core/app/apps/agent_chat/app_generator.py @@ -167,7 +167,11 @@ class AgentChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=( + args.get("parent_message_id") + if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI} + else UUID_NIL + ), user_id=user.id, stream=streaming, invoke_from=invoke_from, diff --git a/api/core/app/apps/chat/app_generator.py b/api/core/app/apps/chat/app_generator.py index 58afefe296..254382f4bd 100644 --- a/api/core/app/apps/chat/app_generator.py +++ b/api/core/app/apps/chat/app_generator.py @@ -161,7 +161,11 @@ class ChatAppGenerator(MessageBasedAppGenerator): ), query=query, files=list(file_objs), - parent_message_id=args.get("parent_message_id") if invoke_from != InvokeFrom.SERVICE_API else UUID_NIL, + parent_message_id=( + args.get("parent_message_id") + if invoke_from not in {InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI} + else UUID_NIL + ), user_id=user.id, invoke_from=invoke_from, extras=extras, diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index 4a741d3154..297279954e 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -53,6 +53,14 @@ from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager from core.workflow.human_input_forms import load_form_tokens_by_form_id from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons + +# Maps the entry surface a workflow was invoked from to the HITL surface that +# its resume tokens must be filtered for. Surfaces not in this map fall back to +# the general priority ordering (typically CONSOLE > BACKSTAGE). +_INVOKE_FROM_TO_HITL_SURFACE: Mapping[InvokeFrom, HumanInputSurface] = { + InvokeFrom.SERVICE_API: HumanInputSurface.SERVICE_API, + InvokeFrom.OPENAPI: HumanInputSurface.OPENAPI, +} from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -340,11 +348,7 @@ class WorkflowResponseConverter: form_token_by_form_id = load_form_tokens_by_form_id( human_input_form_ids, session=session, - surface=( - HumanInputSurface.SERVICE_API - if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API - else None - ), + surface=_INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from), ) # Reconnect paths must preserve the same pause-reason contract as live streams; diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 80f8e3ad4a..e52e1e9c9d 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -731,6 +731,8 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): match invoke_from: case InvokeFrom.SERVICE_API: created_from = WorkflowAppLogCreatedFrom.SERVICE_API + case InvokeFrom.OPENAPI: + created_from = WorkflowAppLogCreatedFrom.OPENAPI case InvokeFrom.EXPLORE: created_from = WorkflowAppLogCreatedFrom.INSTALLED_APP case InvokeFrom.WEB_APP: diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py index 09992f4bbf..d4baf1f0df 100644 --- a/api/core/app/entities/app_invoke_entities.py +++ b/api/core/app/entities/app_invoke_entities.py @@ -24,6 +24,7 @@ class UserFrom(StrEnum): class InvokeFrom(StrEnum): SERVICE_API = "service-api" + OPENAPI = "openapi" WEB_APP = "web-app" TRIGGER = "trigger" EXPLORE = "explore" @@ -42,6 +43,7 @@ class InvokeFrom(StrEnum): InvokeFrom.EXPLORE: "explore_app", InvokeFrom.TRIGGER: "trigger", InvokeFrom.SERVICE_API: "api", + InvokeFrom.OPENAPI: "openapi", } return source_mapping.get(self, "dev") diff --git a/api/core/workflow/human_input_forms.py b/api/core/workflow/human_input_forms.py index b02f69ec33..fe3c161a32 100644 --- a/api/core/workflow/human_input_forms.py +++ b/api/core/workflow/human_input_forms.py @@ -63,7 +63,7 @@ def _get_surface_form_token( *, surface: HumanInputSurface | None, ) -> str | None: - if surface == HumanInputSurface.SERVICE_API: + if surface in {HumanInputSurface.SERVICE_API, HumanInputSurface.OPENAPI}: for recipient_type, token in recipients: if recipient_type == RecipientType.STANDALONE_WEB_APP and token: return token diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py index 798eb8723f..8d231e2437 100644 --- a/api/core/workflow/human_input_policy.py +++ b/api/core/workflow/human_input_policy.py @@ -11,13 +11,15 @@ from models.human_input import RecipientType class HumanInputSurface(StrEnum): SERVICE_API = "service_api" CONSOLE = "console" + OPENAPI = "openapi" -# Service API is intentionally narrower than other surfaces: app-token callers +# SERVICE_API and OPENAPI are intentionally narrower than CONSOLE: token callers # should only be able to act on end-user web forms, not internal console flows. _ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = { HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}), HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}), + HumanInputSurface.OPENAPI: frozenset({RecipientType.STANDALONE_WEB_APP}), } # A single HITL form can have multiple recipient records; this shared priority diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index 254310cd2a..b7c58d6444 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -45,6 +45,7 @@ SPEC_TARGETS: tuple[SpecTarget, ...] = ( SpecTarget(route="/console/api/swagger.json", filename="console-swagger.json", namespace="console"), SpecTarget(route="/api/swagger.json", filename="web-swagger.json", namespace="web"), SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"), + SpecTarget(route="/openapi/v1/swagger.json", filename="openapi-swagger.json", namespace="openapi"), ) @@ -161,6 +162,8 @@ def create_spec_app() -> Flask: from controllers.console import bp as console_bp from controllers.console import console_ns + from controllers.openapi import bp as openapi_bp + from controllers.openapi import openapi_ns from controllers.service_api import bp as service_api_bp from controllers.service_api import service_api_ns from controllers.web import bp as web_bp @@ -169,8 +172,9 @@ def create_spec_app() -> Flask: app.register_blueprint(console_bp) app.register_blueprint(web_bp) app.register_blueprint(service_api_bp) + app.register_blueprint(openapi_bp) - for namespace in (console_ns, web_ns, service_api_ns): + for namespace in (console_ns, web_ns, service_api_ns, openapi_ns): for api in namespace.apis: _materialize_inline_model_definitions(api) @@ -201,6 +205,13 @@ def _registered_models(namespace: str) -> dict[str, object]: for api in service_api_ns.apis: models.update(api.models) return models + if namespace == "openapi": + from controllers.openapi import openapi_ns + + models = dict(openapi_ns.models) + for api in openapi_ns.apis: + models.update(api.models) + return models raise ValueError(f"unknown Swagger namespace: {namespace}") diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py index 7d13f0c061..7b1481b353 100644 --- a/api/extensions/ext_blueprints.py +++ b/api/extensions/ext_blueprints.py @@ -8,6 +8,8 @@ AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) EMBED_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE) EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id") +OPENAPI_HEADERS: tuple[str, ...] = ("Authorization", "Content-Type", HEADER_NAME_CSRF_TOKEN) +OPENAPI_MAX_AGE_SECONDS: int = 600 def _apply_cors_once(bp, /, **cors_kwargs): @@ -29,6 +31,7 @@ def init_app(app: DifyApp): from controllers.files import bp as files_bp from controllers.inner_api import bp as inner_api_bp from controllers.mcp import bp as mcp_bp + from controllers.openapi import bp as openapi_bp from controllers.service_api import bp as service_api_bp from controllers.trigger import bp as trigger_bp from controllers.web import bp as web_bp @@ -41,6 +44,23 @@ def init_app(app: DifyApp): ) app.register_blueprint(service_api_bp) + if dify_config.OPENAPI_ENABLED: + # User-scoped programmatic API. Default empty allowlist = same-origin + # only; expand via OPENAPI_CORS_ALLOW_ORIGINS for third-party + # integrations. supports_credentials so cookie-authed approve/deny + # work; cross-origin OPTIONS without an allowed origin will fail + # the same as on the console blueprint. + _apply_cors_once( + openapi_bp, + resources={r"/*": {"origins": dify_config.OPENAPI_CORS_ALLOW_ORIGINS}}, + supports_credentials=True, + allow_headers=list(OPENAPI_HEADERS), + methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + expose_headers=list(EXPOSED_HEADERS), + max_age=OPENAPI_MAX_AGE_SECONDS, + ) + app.register_blueprint(openapi_bp) + _apply_cors_once( web_bp, resources={ diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 340f514fcc..fce065eda9 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -222,6 +222,12 @@ def init_app(app: DifyApp) -> Celery: "task": "schedule.clean_workflow_runs_task.clean_workflow_runs_task", "schedule": crontab(minute="0", hour="0"), } + if dify_config.ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK: + imports.append("schedule.clean_oauth_access_tokens_task") + beat_schedule["clean_oauth_access_tokens_task"] = { + "task": "schedule.clean_oauth_access_tokens_task.clean_oauth_access_tokens_task", + "schedule": crontab(minute="0", hour="5", day_of_month=f"*/{day}"), + } if dify_config.ENABLE_WORKFLOW_SCHEDULE_POLLER_TASK: imports.append("schedule.workflow_schedule_task") beat_schedule["workflow_schedule_task"] = { diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index bc59eaca63..9f9372888f 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -12,7 +12,7 @@ from constants import HEADER_NAME_APP_CODE from dify_app import DifyApp from extensions.ext_database import db from libs.passport import PassportService -from libs.token import extract_access_token, extract_webapp_passport +from libs.token import extract_access_token, extract_console_cookie_token, extract_webapp_passport from models import Account, Tenant, TenantAccountJoin from models.model import AppMCPServer, EndUser from services.account_service import AccountService @@ -84,6 +84,24 @@ def load_user_from_request(request_from_flask_login: Request) -> LoginUser | Non logged_in_account = AccountService.load_logged_in_account(account_id=user_id) return logged_in_account + elif request.blueprint == "openapi": + # Account-branch device-flow approval routes (approve / deny / + # approval-context) sit under @login_required and authenticate via + # the console session cookie. Cookie-only on purpose — bearer + # tokens (dfoa_/dfoe_) live on the Authorization header and are + # validated by AppPipeline, not flask-login. + cookie_token = extract_console_cookie_token(request) + if not cookie_token: + return None + try: + decoded = PassportService().verify(cookie_token) + except Exception: + return None + user_id = decoded.get("user_id") + source = decoded.get("token_source") + if source or not user_id: + return None + return AccountService.load_logged_in_account(account_id=user_id) elif request.blueprint == "web": app_code = request.headers.get(HEADER_NAME_APP_CODE) webapp_token = extract_webapp_passport(app_code, request) if app_code else None diff --git a/api/extensions/ext_oauth_bearer.py b/api/extensions/ext_oauth_bearer.py new file mode 100644 index 0000000000..58c2ac2d2c --- /dev/null +++ b/api/extensions/ext_oauth_bearer.py @@ -0,0 +1,23 @@ +"""Bind the bearer authenticator at startup. Must run after ext_database +and ext_redis (needs both factories). +""" + +from __future__ import annotations + +from configs import dify_config +from dify_app import DifyApp +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs.oauth_bearer import build_and_bind + + +def is_enabled() -> bool: + return dify_config.ENABLE_OAUTH_BEARER + + +def init_app(app: DifyApp) -> None: + # scoped_session isn't a context manager; request teardown closes it. + def session_factory(): + return db.session + + build_and_bind(session_factory=session_factory, redis_client=redis_client) diff --git a/api/libs/device_flow_security.py b/api/libs/device_flow_security.py new file mode 100644 index 0000000000..9f4c1f56f6 --- /dev/null +++ b/api/libs/device_flow_security.py @@ -0,0 +1,205 @@ +"""Device-flow security primitives: enterprise_only gate, approval-grant +cookie mint/verify/consume, and anti-framing headers. +""" + +from __future__ import annotations + +import logging +import secrets +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from functools import wraps + +from flask import Blueprint +from pydantic import ValidationError +from werkzeug.exceptions import NotFound + +from libs import jws +from libs.token import is_secure +from services.feature_service import FeatureService, LicenseStatus + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# enterprise_only decorator +# ============================================================================ + + +# Fail-closed: any non-EE-active status (default NONE on CE, plus INACTIVE / EXPIRED / LOST) +# is denied. Future LicenseStatus values default to denial unless explicitly admitted. +_EE_ENABLED_STATUSES = {LicenseStatus.ACTIVE, LicenseStatus.EXPIRING} + + +def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """404 on CE, passthrough on EE. Apply before rate-limit so CE + responses don't consume the bucket. + """ + + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs): + settings = FeatureService.get_system_features() + if settings.license.status not in _EE_ENABLED_STATUSES: + raise NotFound() + return view(*args, **kwargs) + + return decorated + + +# ============================================================================ +# approval_grant cookie +# ============================================================================ + + +APPROVAL_GRANT_COOKIE_NAME = "device_approval_grant" +APPROVAL_GRANT_COOKIE_PATH = "/openapi/v1/oauth/device" +APPROVAL_GRANT_COOKIE_TTL_SECONDS = 300 # 5 min +NONCE_TTL_SECONDS = 600 # 2x cookie TTL — defeats clock-skew late replay +NONCE_KEY_FMT = "device_approval_grant_nonce:{nonce}" +SSO_ASSERTION_NONCE_KEY_FMT = "sso_assertion_nonce:{nonce}" + + +@dataclass(frozen=True, slots=True) +class ApprovalGrantClaims: + subject_email: str + subject_issuer: str + user_code: str + nonce: str + csrf_token: str + expires_at: datetime + + +def mint_approval_grant( + *, + keyset: jws.KeySet, + iss: str, + subject_email: str, + subject_issuer: str, + user_code: str, +) -> tuple[str, ApprovalGrantClaims]: + """Use ``approval_grant_cookie_kwargs`` to set the cookie — single + source of truth for Path/HttpOnly/Secure/SameSite. + """ + now = datetime.now(UTC) + exp = now + timedelta(seconds=APPROVAL_GRANT_COOKIE_TTL_SECONDS) + nonce = _random_opaque() + csrf_token = _random_opaque() + + payload = { + "iss": iss, + "subject_email": subject_email, + "subject_issuer": subject_issuer, + "user_code": user_code, + "nonce": nonce, + "csrf_token": csrf_token, + } + token = jws.sign(keyset, payload, aud=jws.AUD_APPROVAL_GRANT, ttl_seconds=APPROVAL_GRANT_COOKIE_TTL_SECONDS) + + return token, ApprovalGrantClaims( + subject_email=subject_email, + subject_issuer=subject_issuer, + user_code=user_code, + nonce=nonce, + csrf_token=csrf_token, + expires_at=exp, + ) + + +def verify_approval_grant(keyset: jws.KeySet, token: str) -> ApprovalGrantClaims: + """Sig + aud + exp only — nonce consumption is the caller's job.""" + # lazy import: breaks libs → controllers cycle + from controllers.openapi._models import ApprovalGrantClaimsPayload + + raw = jws.verify(keyset, token, expected_aud=jws.AUD_APPROVAL_GRANT) + try: + parsed = ApprovalGrantClaimsPayload.model_validate(raw) + except ValidationError as e: + raise jws.VerifyError(f"claim shape invalid: {e}") from e + + return ApprovalGrantClaims( + subject_email=parsed.subject_email, + subject_issuer=parsed.subject_issuer, + user_code=parsed.user_code, + nonce=parsed.nonce, + csrf_token=parsed.csrf_token, + expires_at=datetime.fromtimestamp(raw["exp"], tz=UTC), + ) + + +def consume_approval_grant_nonce(redis_client, nonce: str) -> bool: + if not nonce: + return False + return bool( + redis_client.set( + NONCE_KEY_FMT.format(nonce=nonce), + "1", + nx=True, + ex=NONCE_TTL_SECONDS, + ) + ) + + +def consume_sso_assertion_nonce(redis_client, nonce: str) -> bool: + if not nonce: + return False + return bool( + redis_client.set( + SSO_ASSERTION_NONCE_KEY_FMT.format(nonce=nonce), + "1", + nx=True, + ex=NONCE_TTL_SECONDS, + ) + ) + + +def approval_grant_cookie_kwargs(value: str) -> dict: + """``secure`` follows is_secure() so HTTP-only deployments don't + silently drop the cookie. + """ + return { + "key": APPROVAL_GRANT_COOKIE_NAME, + "value": value, + "max_age": APPROVAL_GRANT_COOKIE_TTL_SECONDS, + "path": APPROVAL_GRANT_COOKIE_PATH, + "secure": is_secure(), + "httponly": True, + "samesite": "Lax", + } + + +def approval_grant_cleared_cookie_kwargs() -> dict: + return { + "key": APPROVAL_GRANT_COOKIE_NAME, + "value": "", + "max_age": 0, + "path": APPROVAL_GRANT_COOKIE_PATH, + "secure": is_secure(), + "httponly": True, + "samesite": "Lax", + } + + +def _random_opaque() -> str: + return secrets.token_urlsafe(16) + + +# ============================================================================ +# Anti-framing headers +# ============================================================================ + + +_ANTI_FRAMING_HEADERS = { + "X-Frame-Options": "DENY", + "Content-Security-Policy": "frame-ancestors 'none'", +} + + +def attach_anti_framing(bp: Blueprint) -> None: + """X-Frame-Options + CSP on every response from ``bp`` (CI invariant #4).""" + + @bp.after_request + def _apply_headers(response): # pyright: ignore[reportUnusedFunction] + for name, value in _ANTI_FRAMING_HEADERS.items(): + response.headers.setdefault(name, value) + return response diff --git a/api/libs/external_api.py b/api/libs/external_api.py index 64eb99a42b..dd2d734784 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -76,6 +76,7 @@ def register_external_error_handlers(api: Api): def handle_value_error(e: ValueError): got_request_exception.send(current_app, exception=e) + current_app.logger.exception("value_error in request handler") status_code = 400 data = {"code": "invalid_param", "message": str(e), "status": status_code} return data, status_code diff --git a/api/libs/helper.py b/api/libs/helper.py index 57e808a408..b66079fd5f 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -595,3 +595,18 @@ class RateLimiter: self._redis_client.zadd(key, {member: current_time}) self._redis_client.expire(key, self.time_window * 2) + + def seconds_until_available(self, email: str) -> int: + """Seconds until the oldest in-window entry expires, freeing a slot. + + Defensive floor of 1 second. Caller should only invoke this after + is_rate_limited() returned True. + """ + key = self._get_key(email) + oldest = cast(Any, self._redis_client).zrange(key, 0, 0, withscores=True) + if not oldest: + return 1 + _member, score = oldest[0] + free_at = int(score) + self.time_window + remaining = free_at - int(time.time()) + return max(remaining, 1) diff --git a/api/libs/jws.py b/api/libs/jws.py new file mode 100644 index 0000000000..692ccb39fa --- /dev/null +++ b/api/libs/jws.py @@ -0,0 +1,108 @@ +"""HS256 compact JWS keyed on the shared Dify SECRET_KEY. Used by the SSO +state envelope, external subject assertion, and approval-grant cookie — +all three share one key-set so api ↔ enterprise can verify each other. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import jwt + +from configs import dify_config + +AUD_STATE_ENVELOPE = "api.sso.state_envelope" +AUD_EXT_SUBJECT_ASSERTION = "api.device_flow.external_subject_assertion" +AUD_APPROVAL_GRANT = "api.device_flow.approval_grant" + +ACTIVE_KID_V1 = "dify-shared-v1" + + +class KeySetError(Exception): + pass + + +class KeySet: + """``from_entries`` reserves multi-kid construction for rotation slots.""" + + def __init__(self, entries: dict[str, bytes], active_kid: str) -> None: + if active_kid not in entries: + raise KeySetError(f"active kid {active_kid!r} missing from key-set") + if not entries[active_kid]: + raise KeySetError(f"active kid {active_kid!r} has empty secret") + self._entries: dict[str, bytes] = {k: bytes(v) for k, v in entries.items()} + self._active_kid = active_kid + + @classmethod + def from_shared_secret(cls) -> KeySet: + secret = dify_config.SECRET_KEY + if not secret: + raise KeySetError("dify_config.SECRET_KEY is empty; cannot build key-set") + return cls({ACTIVE_KID_V1: secret.encode("utf-8")}, ACTIVE_KID_V1) + + @classmethod + def from_entries(cls, entries: dict[str, bytes], active_kid: str) -> KeySet: + return cls(entries, active_kid) + + @property + def active_kid(self) -> str: + return self._active_kid + + def lookup(self, kid: str) -> bytes | None: + return self._entries.get(kid) + + +def sign(keyset: KeySet, payload: dict, aud: str, ttl_seconds: int) -> str: + """``iat`` + ``exp`` are injected here; callers must not set them.""" + if "aud" in payload or "iat" in payload or "exp" in payload: + raise ValueError("reserved claim present in payload (aud/iat/exp)") + if ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + + kid = keyset.active_kid + secret = keyset.lookup(kid) + if secret is None: + raise KeySetError(f"active kid {kid!r} lookup miss") + + iat = datetime.now(UTC) + exp = iat + timedelta(seconds=ttl_seconds) + claims = {**payload, "aud": aud, "iat": iat, "exp": exp} + return jwt.encode( + claims, + secret, + algorithm="HS256", + headers={"kid": kid, "typ": "JWT"}, + ) + + +class VerifyError(Exception): + pass + + +def verify(keyset: KeySet, token: str, expected_aud: str) -> dict: + """Unknown kid is rejected — never fall back to the active kid, since + a past kid value would otherwise be forgeable by anyone who saw it. + """ + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError as e: + raise VerifyError(f"decode header: {e}") from e + kid = header.get("kid") + if not kid: + raise VerifyError("no kid in header") + secret = keyset.lookup(kid) + if secret is None: + raise VerifyError(f"unknown kid {kid!r}") + try: + return jwt.decode( + token, + secret, + algorithms=["HS256"], + audience=expected_aud, + ) + except jwt.ExpiredSignatureError as e: + raise VerifyError("token expired") from e + except jwt.InvalidAudienceError as e: + raise VerifyError("aud mismatch") from e + except jwt.PyJWTError as e: + raise VerifyError(f"decode: {e}") from e diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py new file mode 100644 index 0000000000..6e8678eca0 --- /dev/null +++ b/api/libs/oauth_bearer.py @@ -0,0 +1,685 @@ +"""OAuth bearer primitives. + +To add a token kind: write a Resolver, add a SubjectType + Accepts member, +append a TokenKind to build_registry, and update _SUBJECT_TO_ACCEPT. +Authenticator + validate_bearer stay untouched. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import uuid +from collections.abc import Callable, Iterable +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import StrEnum +from functools import wraps +from typing import Literal, ParamSpec, Protocol, TypeVar + +from flask import request +from sqlalchemy import select, update +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden, ServiceUnavailable, Unauthorized + +from configs import dify_config +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from libs.rate_limit import enforce_bearer_rate_limit +from models import Account, OAuthAccessToken, TenantAccountJoin + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Contract — types, enums, protocols +# ============================================================================ + + +class SubjectType(StrEnum): + ACCOUNT = "account" + EXTERNAL_SSO = "external_sso" + + +class Scope(StrEnum): + """Catalog of bearer scopes recognised by the openapi surface. + + `FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies + any per-route `require_scope`. `dfoe_` tokens carry the per-feature scopes + (`APPS_RUN`, `APPS_READ_PERMITTED_EXTERNAL`). + """ + + FULL = "full" + APPS_READ = "apps:read" + APPS_READ_PERMITTED_EXTERNAL = "apps:read:permitted-external" + APPS_RUN = "apps:run" + + +class Accepts(StrEnum): + """Subject types a route is willing to accept as caller.""" + + USER_ACCOUNT = "user_account" + USER_EXT_SSO = "user_ext_sso" + + +ACCEPT_USER_ANY: frozenset[Accepts] = frozenset({Accepts.USER_ACCOUNT, Accepts.USER_EXT_SSO}) +ACCEPT_USER_EXT_SSO: frozenset[Accepts] = frozenset({Accepts.USER_EXT_SSO}) + +_SUBJECT_TO_ACCEPT: dict[SubjectType, Accepts] = { + SubjectType.ACCOUNT: Accepts.USER_ACCOUNT, + SubjectType.EXTERNAL_SSO: Accepts.USER_EXT_SSO, +} + + +@dataclass(frozen=True, slots=True) +class AuthContext: + """Per-request identity published via :data:`_auth_ctx_var` + (see :func:`set_auth_ctx` / :func:`get_auth_ctx`). ``scopes`` / + ``subject_type`` / ``source`` come from the TokenKind, not the DB — + corrupt rows can't elevate scope. + + `verified_tenants` is a snapshot of the Layer-0 verdict cache at + authenticate time. Per-request mutations write through to Redis via + `record_layer0_verdict`; this snapshot is not updated in place (frozen). + """ + + subject_type: SubjectType + subject_email: str | None + subject_issuer: str | None + account_id: uuid.UUID | None + client_id: str | None + scopes: frozenset[Scope] + token_id: uuid.UUID + source: str + expires_at: datetime | None + token_hash: str + verified_tenants: dict[str, bool] = field(default_factory=dict) + + +_auth_ctx_var: ContextVar[AuthContext] = ContextVar("openapi_auth_ctx") + + +def set_auth_ctx(ctx: AuthContext) -> Token[AuthContext]: + return _auth_ctx_var.set(ctx) + + +def reset_auth_ctx(token: Token[AuthContext]) -> None: + _auth_ctx_var.reset(token) + + +def get_auth_ctx() -> AuthContext: + return _auth_ctx_var.get() + + +def try_get_auth_ctx() -> AuthContext | None: + return _auth_ctx_var.get(None) + + +@dataclass(frozen=True, slots=True) +class ResolvedRow: + subject_email: str | None + subject_issuer: str | None + account_id: uuid.UUID | None + client_id: str | None + token_id: uuid.UUID + expires_at: datetime | None + verified_tenants: dict[str, bool] = field(default_factory=dict) + + def to_cache(self) -> dict: + return { + "subject_email": self.subject_email, + "subject_issuer": self.subject_issuer, + "account_id": str(self.account_id) if self.account_id else None, + "client_id": self.client_id, + "token_id": str(self.token_id), + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "verified_tenants": dict(self.verified_tenants), + } + + @classmethod + def from_cache(cls, data: dict) -> ResolvedRow: + return cls( + subject_email=data["subject_email"], + subject_issuer=data["subject_issuer"], + account_id=uuid.UUID(data["account_id"]) if data["account_id"] else None, + client_id=data.get("client_id"), + token_id=uuid.UUID(data["token_id"]), + expires_at=datetime.fromisoformat(data["expires_at"]) if data["expires_at"] else None, + verified_tenants=_coerce_verified_tenants(data.get("verified_tenants")), + ) + + +def _coerce_verified_tenants(raw: object) -> dict[str, bool]: + """Tolerate legacy entries that stored 'ok'/'denied' string verdicts. + + TODO(post-v1.0): remove once the AuthContext cache TTL has fully cycled + on all live deployments (60s TTL → safe to drop one release after rollout). + """ + if not isinstance(raw, dict): + return {} + out: dict[str, bool] = {} + for k, v in raw.items(): + if isinstance(v, bool): + out[k] = v + elif v == "ok": + out[k] = True + elif v == "denied": + out[k] = False + return out + + +class Resolver(Protocol): + def resolve(self, token_hash: str) -> ResolvedRow | None: # pragma: no cover - contract + ... + + +@dataclass(frozen=True, slots=True) +class TokenKind: + prefix: str + subject_type: SubjectType + scopes: frozenset[Scope] + source: str + resolver: Resolver + + def matches(self, token: str) -> bool: + return token.startswith(self.prefix) + + +@dataclass(frozen=True, slots=True) +class MintProfile: + """Single source of truth for (subject_type, prefix, scopes) at mint time. + + Consumers: + - ``build_registry`` reads scopes here so the resolve-time TokenKind + cannot drift from the mint-time intent. + - Device-flow ``approve`` / ``approve-external`` read prefix + scopes + here when calling ``mint_oauth_token`` and ``validate_mint_policy``. + - ``services.openapi.mint_policy.validate_mint_policy`` cross-checks + the (subject_type, prefix, scopes) triple a caller intends to mint + against this table — a caller that assembles its own scope set + from a non-canonical source will fail closed at approve time. + """ + + subject_type: SubjectType + prefix: str + scopes: frozenset[Scope] + + +MINTABLE_PROFILES: dict[SubjectType, MintProfile] = { + SubjectType.ACCOUNT: MintProfile( + subject_type=SubjectType.ACCOUNT, + prefix="dfoa_", + scopes=frozenset({Scope.FULL}), + ), + SubjectType.EXTERNAL_SSO: MintProfile( + subject_type=SubjectType.EXTERNAL_SSO, + prefix="dfoe_", + scopes=frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), + ), +} + + +class InvalidBearerError(Exception): + """Token missing, unknown prefix, or no live row.""" + + +class TokenExpiredError(Exception): + """Hard-expire bookkeeping is the resolver's job before raising.""" + + +# ============================================================================ +# Registry +# ============================================================================ + + +class TokenKindRegistry: + def __init__(self, kinds: Iterable[TokenKind]) -> None: + self._kinds: tuple[TokenKind, ...] = tuple(kinds) + prefixes = [k.prefix for k in self._kinds] + if len(set(prefixes)) != len(prefixes): + raise ValueError(f"duplicate prefix in registry: {prefixes}") + + def find(self, token: str) -> TokenKind | None: + for k in self._kinds: + if k.matches(token): + return k + return None + + def kinds(self) -> tuple[TokenKind, ...]: + return self._kinds + + +# ============================================================================ +# Authenticator +# ============================================================================ + + +def sha256_hex(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +class BearerAuthenticator: + def __init__(self, registry: TokenKindRegistry) -> None: + self._registry = registry + + @property + def registry(self) -> TokenKindRegistry: + return self._registry + + def authenticate(self, token: str) -> AuthContext: + """Identity + per-token rate limit (single source). + + Both the openapi pipeline (`BearerCheck`) and the decorator + (`validate_bearer`) call this — rate-limit fires exactly once per + request regardless of which path hosts the route. + """ + kind = self._registry.find(token) + if kind is None: + raise InvalidBearerError("invalid_bearer") + token_hash = sha256_hex(token) + enforce_bearer_rate_limit(token_hash) + row = kind.resolver.resolve(token_hash) + if row is None: + raise InvalidBearerError("invalid_bearer") + return AuthContext( + subject_type=kind.subject_type, + subject_email=row.subject_email, + subject_issuer=row.subject_issuer, + account_id=row.account_id, + client_id=row.client_id, + scopes=kind.scopes, + token_id=row.token_id, + source=kind.source, + expires_at=row.expires_at, + token_hash=token_hash, + verified_tenants=dict(row.verified_tenants), + ) + + +# ============================================================================ +# OAuth access token resolver (PAT resolver would be a sibling class) +# ============================================================================ + +TOKEN_CACHE_KEY_FMT = "auth:token:{hash}" +POSITIVE_TTL_SECONDS = 60 +NEGATIVE_TTL_SECONDS = 10 +AUDIT_OAUTH_EXPIRED = "oauth.token_expired" + +ScopeVariant = Literal["account", "external_sso"] + + +class OAuthAccessTokenResolver: + """``.for_account()`` / ``.for_external_sso()`` are variant-scoped views + sharing DB + cache plumbing. + """ + + def __init__( + self, + session_factory, + redis_client, + positive_ttl: int = POSITIVE_TTL_SECONDS, + negative_ttl: int = NEGATIVE_TTL_SECONDS, + ) -> None: + self.session_factory = session_factory + self._redis = redis_client + self._positive_ttl = positive_ttl + self._negative_ttl = negative_ttl + + def for_account(self) -> Resolver: + return _VariantResolver(self, variant="account") + + def for_external_sso(self) -> Resolver: + return _VariantResolver(self, variant="external_sso") + + def _cache_key(self, token_hash: str) -> str: + return TOKEN_CACHE_KEY_FMT.format(hash=token_hash) + + def cache_get(self, token_hash: str) -> ResolvedRow | None | Literal["invalid"]: + raw = self._redis.get(self._cache_key(token_hash)) + if raw is None: + return None + text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw + if text == "invalid": + return "invalid" + try: + return ResolvedRow.from_cache(json.loads(text)) + except (ValueError, KeyError): + logger.warning("auth:token cache entry malformed; treating as miss") + return None + + def cache_set_positive(self, token_hash: str, row: ResolvedRow) -> None: + self._redis.setex( + self._cache_key(token_hash), + self._positive_ttl, + json.dumps(row.to_cache()), + ) + + def cache_set_negative(self, token_hash: str) -> None: + self._redis.setex(self._cache_key(token_hash), self._negative_ttl, "invalid") + + def hard_expire(self, session: Session, row_id: uuid.UUID | str, token_hash: str) -> None: + """Atomic CAS — only the worker that flips revoked_at emits audit; + replays are idempotent. + """ + stmt = ( + update(OAuthAccessToken) + .where(OAuthAccessToken.id == row_id, OAuthAccessToken.revoked_at.is_(None)) + .values(revoked_at=datetime.now(UTC), token_hash=None) + ) + result = session.execute(stmt) + session.commit() + if result.rowcount == 1: # type: ignore + logger.warning( + "audit: %s token_id=%s", + AUDIT_OAUTH_EXPIRED, + row_id, + extra={"audit": True, "token_id": str(row_id)}, + ) + self._redis.delete(self._cache_key(token_hash)) + self.cache_set_negative(token_hash) + + +class _VariantResolver: + def __init__(self, parent: OAuthAccessTokenResolver, variant: ScopeVariant) -> None: + self._parent = parent + self._variant = variant + + def resolve(self, token_hash: str) -> ResolvedRow | None: + cached = self._parent.cache_get(token_hash) + if cached == "invalid": + return None + if cached is not None and not isinstance(cached, str): + if not self._matches_variant(cached): + return None + return cached + + # Flask-SQLAlchemy's scoped_session is request-bound and not a + # context manager; use it directly. + session = self._parent.session_factory() + row = self._load_from_db(session, token_hash) + if row is None: + self._parent.cache_set_negative(token_hash) + return None + + now = datetime.now(UTC) + if row.expires_at is not None and row.expires_at <= now: + self._parent.hard_expire(session, row.id, token_hash) + return None + + if not self._matches_variant_model(row): + logger.error( + "internal_state_invariant: account_id/prefix mismatch token_id=%s prefix=%s", + row.id, + row.prefix, + ) + return None + + resolved = ResolvedRow( + subject_email=row.subject_email, + subject_issuer=row.subject_issuer, + account_id=uuid.UUID(str(row.account_id)) if row.account_id else None, + client_id=row.client_id, + token_id=uuid.UUID(str(row.id)), + expires_at=row.expires_at, + ) + self._parent.cache_set_positive(token_hash, resolved) + return resolved + + def _matches_variant(self, row: ResolvedRow) -> bool: + has_account = row.account_id is not None + if self._variant == "account": + return has_account + return not has_account + + def _matches_variant_model(self, row: OAuthAccessToken) -> bool: + has_account = row.account_id is not None + if self._variant == "account": + return has_account and row.prefix == "dfoa_" + return (not has_account) and row.prefix == "dfoe_" + + def _load_from_db(self, session: Session, token_hash: str) -> OAuthAccessToken | None: + return ( + session.query(OAuthAccessToken) + .filter( + OAuthAccessToken.token_hash == token_hash, + OAuthAccessToken.revoked_at.is_(None), + ) + .one_or_none() + ) + + +# ============================================================================ +# Layer 0 — workspace membership cache + helper +# ============================================================================ + + +def record_layer0_verdict(token_hash: str, tenant_id: str, verdict: bool) -> None: + """Merge a Layer-0 membership verdict into the AuthContext cache entry at + `auth:token:{hash}`. No-op if entry missing/expired/invalid — next request + rebuilds via authenticate() and re-runs Layer 0. + """ + cache_key = TOKEN_CACHE_KEY_FMT.format(hash=token_hash) + raw = redis_client.get(cache_key) + if raw is None: + return + text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw + if text == "invalid": + return + try: + data = json.loads(text) + except (ValueError, KeyError): + return + ttl = redis_client.ttl(cache_key) + if ttl <= 0: + return + data.setdefault("verified_tenants", {})[tenant_id] = verdict + redis_client.setex(cache_key, ttl, json.dumps(data)) + + +def check_workspace_membership( + *, + account_id: uuid.UUID | str, + tenant_id: str, + token_hash: str, + cached_verdicts: dict[str, bool], +) -> None: + """Layer-0 enforcement core. Raises `Forbidden` on deny, returns on allow. + + Shared by the pipeline step (`WorkspaceMembershipCheck`) and the + inline helper (`require_workspace_member`). Caller is responsible for + short-circuiting on EE / SSO subjects before invoking — this function + runs the membership + active-status checks unconditionally. + """ + cached = cached_verdicts.get(tenant_id) + if cached is True: + return + if cached is False: + raise Forbidden("workspace_membership_revoked") + + join = db.session.execute( + select(TenantAccountJoin.id).where( + TenantAccountJoin.account_id == account_id, + TenantAccountJoin.tenant_id == tenant_id, + ) + ).scalar_one_or_none() + if join is None: + record_layer0_verdict(token_hash, tenant_id, False) + raise Forbidden("workspace_membership_revoked") + + status = db.session.execute(select(Account.status).where(Account.id == account_id)).scalar_one_or_none() + if status != "active": + record_layer0_verdict(token_hash, tenant_id, False) + raise Forbidden("workspace_membership_revoked") + + record_layer0_verdict(token_hash, tenant_id, True) + + +def require_workspace_member(ctx: AuthContext, tenant_id: str) -> None: + """AuthContext-flavoured wrapper around `check_workspace_membership`. + + No-op on EE (gateway RBAC owns tenant isolation) and for SSO subjects + (no `tenant_account_joins` row by definition). + """ + if dify_config.ENTERPRISE_ENABLED: + return + if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None: + return + check_workspace_membership( + account_id=ctx.account_id, + tenant_id=tenant_id, + token_hash=ctx.token_hash, + cached_verdicts=ctx.verified_tenants, + ) + + +# ============================================================================ +# Decorator — route-level bearer gate +# ============================================================================ + + +_authenticator: BearerAuthenticator | None = None + + +def bind_authenticator(authenticator: BearerAuthenticator) -> None: + global _authenticator + _authenticator = authenticator + + +def get_authenticator() -> BearerAuthenticator: + if _authenticator is None: + raise RuntimeError("BearerAuthenticator not bound; call bind_authenticator at startup") + return _authenticator + + +def extract_bearer(req) -> str | None: + """Pull the bearer token out of an HTTP request's Authorization header. + + Used by both attachment paths (the ``validate_bearer`` decorator and the + openapi ``Pipeline.guard``) so the parsing rule lives in one place. Pipeline + callers extract once at the boundary and pass the token through ``Context`` + so steps stay independent of the request object. + """ + header = req.headers.get("Authorization", "") + scheme, _, value = header.partition(" ") + if scheme.lower() != "bearer" or not value: + return None + return value.strip() + + +_DP = ParamSpec("_DP") +_DR = TypeVar("_DR") + + +def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _DR]], Callable[_DP, _DR]]: + """Opt-in: omitting it leaves the route unauthenticated. + + Resolves user-level OAuth bearers (``dfoa_`` / ``dfoe_``). Legacy + ``app-`` keys belong to ``service_api/wraps.py:validate_app_token`` + and are rejected here as the wrong auth scheme for this surface. + """ + + def wrap(fn: Callable[_DP, _DR]) -> Callable[_DP, _DR]: + @wraps(fn) + def inner(*args: _DP.args, **kwargs: _DP.kwargs) -> _DR: + token = extract_bearer(request) + if token is None: + raise Unauthorized("missing bearer token") + + if _authenticator is None: + raise ServiceUnavailable("bearer_auth_disabled: set ENABLE_OAUTH_BEARER=true to enable") + + try: + ctx = get_authenticator().authenticate(token) + except InvalidBearerError as e: + raise Unauthorized(str(e)) + + if _SUBJECT_TO_ACCEPT[ctx.subject_type] not in accept: + raise Forbidden("token subject type not accepted here") + + # Try/finally pairing — the WSGI worker thread is reused + # across requests, so a leaked ContextVar would publish the + # previous caller's identity to the next request. + reset_token = set_auth_ctx(ctx) + try: + return fn(*args, **kwargs) + finally: + reset_auth_ctx(reset_token) + + return inner + + return wrap + + +def bearer_feature_required[**P, R](fn: Callable[P, R]) -> Callable[P, R]: + """503 if ENABLE_OAUTH_BEARER is off — minted tokens would be unusable + without the authenticator, so fail fast instead of approving silently. + """ + + @wraps(fn) + def inner(*args: P.args, **kwargs: P.kwargs) -> R: + if not dify_config.ENABLE_OAUTH_BEARER: + raise ServiceUnavailable("bearer_auth_disabled: set ENABLE_OAUTH_BEARER=true to enable") + return fn(*args, **kwargs) + + return inner + + +def require_scope(scope: Scope) -> Callable: + """Route-level scope gate — must run AFTER validate_bearer so that + the auth ContextVar is set. Raises ``Forbidden('insufficient_scope: ')`` + when the bearer lacks both the requested scope and ``Scope.FULL``. + """ + + def wrap(fn: Callable) -> Callable: + @wraps(fn) + def inner(*args, **kwargs): + ctx = try_get_auth_ctx() + if ctx is None: + raise RuntimeError( + "require_scope used without validate_bearer; stack @validate_bearer above @require_scope" + ) + if Scope.FULL not in ctx.scopes and scope not in ctx.scopes: + raise Forbidden(f"insufficient_scope: {scope}") + return fn(*args, **kwargs) + + return inner + + return wrap + + +# ============================================================================ +# Wiring — called once from the app factory +# ============================================================================ + + +def build_registry(session_factory, redis_client) -> TokenKindRegistry: + oauth = OAuthAccessTokenResolver(session_factory, redis_client) + account = MINTABLE_PROFILES[SubjectType.ACCOUNT] + external = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] + return TokenKindRegistry( + [ + TokenKind( + prefix=account.prefix, + subject_type=account.subject_type, + scopes=account.scopes, + source="oauth_account", + resolver=oauth.for_account(), + ), + TokenKind( + prefix=external.prefix, + subject_type=external.subject_type, + scopes=external.scopes, + source="oauth_external_sso", + resolver=oauth.for_external_sso(), + ), + ] + ) + + +def build_and_bind(session_factory, redis_client) -> BearerAuthenticator: + registry = build_registry(session_factory, redis_client) + auth = BearerAuthenticator(registry) + bind_authenticator(auth) + return auth diff --git a/api/libs/rate_limit.py b/api/libs/rate_limit.py new file mode 100644 index 0000000000..4dad6bff1d --- /dev/null +++ b/api/libs/rate_limit.py @@ -0,0 +1,147 @@ +"""Typed rate-limit decorator over ``libs.helper.RateLimiter`` (sliding- +window Redis ZSET). Apply after auth decorators so account/email/token-id +scopes can read the openapi auth ContextVar (see +:func:`libs.oauth_bearer.try_get_auth_ctx`). Use :func:`enforce` when the +bucket key is computed in-handler. RFC-8628 ``slow_down`` is inline — its +response shape isn't generic 429. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +from enum import StrEnum +from functools import wraps +from typing import ParamSpec, TypeVar + +from flask import jsonify, make_response, request, session +from werkzeug.exceptions import TooManyRequests + +from configs import dify_config +from libs.helper import RateLimiter, extract_remote_ip + + +class RateLimitScope(StrEnum): + IP = "ip" + SESSION = "session" + ACCOUNT = "account" + SUBJECT_EMAIL = "subject_email" + TOKEN_ID = "token_id" + + +@dataclass(frozen=True, slots=True) +class RateLimit: + limit: int + window: timedelta + scopes: tuple[RateLimitScope, ...] + + +LIMIT_DEVICE_CODE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,)) +LIMIT_SSO_INITIATE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,)) +LIMIT_APPROVE_EXT_PER_EMAIL = RateLimit(10, timedelta(hours=1), (RateLimitScope.SUBJECT_EMAIL,)) +LIMIT_APPROVE_CONSOLE = RateLimit(10, timedelta(hours=1), (RateLimitScope.SESSION,)) +LIMIT_LOOKUP_PUBLIC = RateLimit(60, timedelta(minutes=5), (RateLimitScope.IP,)) +LIMIT_ME_PER_ACCOUNT = RateLimit(60, timedelta(minutes=1), (RateLimitScope.ACCOUNT,)) +LIMIT_ME_PER_EMAIL = RateLimit(60, timedelta(minutes=1), (RateLimitScope.SUBJECT_EMAIL,)) +LIMIT_BEARER_PER_TOKEN = RateLimit( + limit=dify_config.OPENAPI_RATE_LIMIT_PER_TOKEN, + window=timedelta(minutes=1), + scopes=(RateLimitScope.TOKEN_ID,), # bucket key composed by caller from sha256(token) +) + + +def _one_key(scope: RateLimitScope) -> str: + match scope: + case RateLimitScope.IP: + return f"ip:{extract_remote_ip(request) or 'unknown'}" + case RateLimitScope.SESSION: + return f"session:{session.get('_id', 'anon')}" + case RateLimitScope.ACCOUNT: + from libs.oauth_bearer import try_get_auth_ctx + + ctx = try_get_auth_ctx() + if ctx and ctx.account_id: + return f"account:{ctx.account_id}" + return "account:anon" + case RateLimitScope.SUBJECT_EMAIL: + from libs.oauth_bearer import try_get_auth_ctx + + ctx = try_get_auth_ctx() + if ctx and ctx.subject_email: + return f"subject:{ctx.subject_email}" + return "subject:anon" + case RateLimitScope.TOKEN_ID: + from libs.oauth_bearer import try_get_auth_ctx + + ctx = try_get_auth_ctx() + if ctx and ctx.token_id: + return f"token:{ctx.token_id}" + return "token:anon" + + +def _composite_key(scopes: tuple[RateLimitScope, ...]) -> str: + return "|".join(_one_key(s) for s in scopes) + + +def _limiter_prefix(scopes: tuple[RateLimitScope, ...]) -> str: + return "rl:" + "+".join(s.value for s in scopes) + + +def _build_limiter(spec: RateLimit) -> RateLimiter: + return RateLimiter( + prefix=_limiter_prefix(spec.scopes), + max_attempts=spec.limit, + time_window=int(spec.window.total_seconds()), + ) + + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def rate_limit(spec: RateLimit) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: + """Apply after auth decorators that the scopes read from.""" + limiter = _build_limiter(spec) + + def wrap(fn: Callable[_P, _R]) -> Callable[_P, _R]: + @wraps(fn) + def inner(*args: _P.args, **kwargs: _P.kwargs) -> _R: + key = _composite_key(spec.scopes) + if limiter.is_rate_limited(key): + raise TooManyRequests("rate_limited") + limiter.increment_rate_limit(key) + return fn(*args, **kwargs) + + return inner + + return wrap + + +def enforce(spec: RateLimit, *, key: str) -> None: + """Imperative form — caller composes the bucket key to match scope + semantics (the key is opaque here). + """ + limiter = _build_limiter(spec) + if limiter.is_rate_limited(key): + raise TooManyRequests("rate_limited") + limiter.increment_rate_limit(key) + + +def enforce_bearer_rate_limit(token_hash: str) -> None: + """Per-token rate limit on /openapi/v1/* bearer-authed routes. + + Bucket key = ``token:`` so the same token shares one + bucket across api replicas (Redis-backed sliding window). + """ + limiter = _build_limiter(LIMIT_BEARER_PER_TOKEN) + key = f"token:{token_hash}" + if limiter.is_rate_limited(key): + retry_after = limiter.seconds_until_available(key) + response = make_response( + jsonify({"error": "rate_limited", "retry_after_ms": retry_after * 1000}), + 429, + ) + response.headers["Retry-After"] = str(retry_after) + raise TooManyRequests(response=response) + limiter.increment_rate_limit(key) diff --git a/api/libs/token.py b/api/libs/token.py index 5b043465ac..68048d8c7d 100644 --- a/api/libs/token.py +++ b/api/libs/token.py @@ -72,11 +72,15 @@ def extract_csrf_token_from_cookie(request: Request) -> str | None: return request.cookies.get(_real_cookie_name(COOKIE_NAME_CSRF_TOKEN)) -def extract_access_token(request: Request) -> str | None: - def _try_extract_from_cookie(request: Request) -> str | None: - return request.cookies.get(_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN)) +def extract_console_cookie_token(request: Request) -> str | None: + """Cookie-only console session token. Used by /openapi/v1/oauth/device/* + approval routes, which must not fall through to the Authorization header + (that's where dfoa_/dfoe_ bearers live — they aren't JWTs).""" + return request.cookies.get(_real_cookie_name(COOKIE_NAME_ACCESS_TOKEN)) - return _try_extract_from_cookie(request) or _try_extract_from_header(request) + +def extract_access_token(request: Request) -> str | None: + return extract_console_cookie_token(request) or _try_extract_from_header(request) def extract_webapp_access_token(request: Request) -> str | None: diff --git a/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py b/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py new file mode 100644 index 0000000000..3ad89441a6 --- /dev/null +++ b/api/migrations/versions/2026_05_22_1700-d4a5e1f3c9b7_add_oauth_access_tokens.py @@ -0,0 +1,128 @@ +"""add oauth_access_tokens table + +Revision ID: d4a5e1f3c9b7 +Revises: 97e2e1a644e8 +Create Date: 2026-05-22 17:00:00.000000 + +Table stores user-level OAuth bearer tokens minted via the device-flow grant +(difyctl auth login). PAT storage (personal_access_tokens) is a separate +table not added in this migration. + +Cross-dialect notes: +- UUID columns use ``models.types.StringUUID`` (UUID on PG, CHAR(36) on + MySQL). The application generates ids via ``libs.uuid_utils.uuidv7``; + on PG we additionally set a ``server_default`` so direct SQL inserts + remain valid. +- Indexed text columns are bounded ``VARCHAR(255)`` because MySQL cannot + index ``TEXT`` without an explicit prefix length. +- ``postgresql_where=`` is silently dropped by SQLAlchemy on MySQL, so the + partial-index filters degrade to plain indexes — semantically a + superset, still correct for lookup. The composite unique index on + ``(subject_email, subject_issuer, client_id, device_label)`` enforces + uniqueness across both dialects (NULLs are distinct in both, matching + the rotate-in-place contract documented on ``OAuthAccessToken``). +""" +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "d4a5e1f3c9b7" +down_revision = "97e2e1a644e8" +branch_labels = None +depends_on = None + + +def _is_pg() -> bool: + return op.get_bind().dialect.name == "postgresql" + + +def upgrade(): + id_kwargs: dict = {"nullable": False, "primary_key": True} + if _is_pg(): + # Match the convention established by 2026_05_19_1000 (uuidv7()). + id_kwargs["server_default"] = sa.text("uuidv7()") + + op.create_table( + "oauth_access_tokens", + sa.Column("id", models.types.StringUUID(), **id_kwargs), + sa.Column("subject_email", sa.String(length=255), nullable=False), + sa.Column("subject_issuer", sa.String(length=255), nullable=True), + sa.Column("account_id", models.types.StringUUID(), nullable=True), + sa.Column("client_id", sa.String(length=64), nullable=False), + sa.Column("device_label", sa.String(length=255), nullable=False), + sa.Column("prefix", sa.String(length=8), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=True, unique=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=False, + ), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["account_id"], + ["accounts.id"], + name="fk_oauth_access_tokens_account_id", + ondelete="SET NULL", + ), + ) + + # Partial-index WHERE clauses are PG-only (SQLAlchemy drops the kwarg + # on MySQL → plain index, which is still correct for lookup). + op.create_index( + "idx_oauth_subject_email", + "oauth_access_tokens", + ["subject_email"], + postgresql_where=sa.text("revoked_at IS NULL"), + ) + op.create_index( + "idx_oauth_account", + "oauth_access_tokens", + ["account_id"], + postgresql_where=sa.text("revoked_at IS NULL AND account_id IS NOT NULL"), + ) + op.create_index( + "idx_oauth_client", + "oauth_access_tokens", + ["subject_email", "client_id"], + postgresql_where=sa.text("revoked_at IS NULL"), + ) + op.create_index( + "idx_oauth_token_hash", + "oauth_access_tokens", + ["token_hash"], + postgresql_where=sa.text("revoked_at IS NULL"), + ) + # Rotate-in-place keyed on (subject, client, device). The app always + # writes a non-NULL subject_issuer (account flow uses a sentinel, + # external-SSO uses the verified IdP issuer); without that guarantee + # the composite key would never collide because both PG and MySQL + # treat NULLs as distinct in unique indices. + # + # ``mysql_length`` truncates each text column to 191 chars in the index + # — utf8mb4 makes the per-row index entry (191+191+64+191)*4 = 2548 + # bytes, comfortably under InnoDB's 3072-byte index limit. Collisions + # on the 191-char prefix are vanishingly unlikely for real emails / + # OIDC issuers / device labels, and the app re-checks the full-row + # invariant before issuing a rotation. + op.create_index( + "uq_oauth_active_per_device", + "oauth_access_tokens", + ["subject_email", "subject_issuer", "client_id", "device_label"], + unique=True, + postgresql_where=sa.text("revoked_at IS NULL"), + mysql_length={"subject_email": 191, "subject_issuer": 191, "device_label": 191}, + ) + + +def downgrade(): + op.drop_index("uq_oauth_active_per_device", table_name="oauth_access_tokens") + op.drop_index("idx_oauth_token_hash", table_name="oauth_access_tokens") + op.drop_index("idx_oauth_client", table_name="oauth_access_tokens") + op.drop_index("idx_oauth_account", table_name="oauth_access_tokens") + op.drop_index("idx_oauth_subject_email", table_name="oauth_access_tokens") + op.drop_table("oauth_access_tokens") diff --git a/api/models/__init__.py b/api/models/__init__.py index 531f725dfc..92b6561411 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -86,7 +86,7 @@ from .model import ( TrialApp, UploadFile, ) -from .oauth import DatasourceOauthParamConfig, DatasourceProvider +from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken from .provider import ( LoadBalancingModelConfig, Provider, @@ -199,6 +199,7 @@ __all__ = [ "MessageChain", "MessageFeedback", "MessageFile", + "OAuthAccessToken", "OperationLog", "PinnedConversation", "Provider", diff --git a/api/models/enums.py b/api/models/enums.py index f13fa448db..34d7c35ac5 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -185,6 +185,7 @@ class InvokeFrom(StrEnum): DEBUGGER = "debugger" PUBLISHED_PIPELINE = "published" VALIDATION = "validation" + OPENAPI = "openapi" @classmethod def value_of(cls, value: str) -> "InvokeFrom": @@ -197,6 +198,7 @@ class InvokeFrom(StrEnum): InvokeFrom.EXPLORE: "explore_app", InvokeFrom.TRIGGER: "trigger", InvokeFrom.SERVICE_API: "api", + InvokeFrom.OPENAPI: "openapi", } return source_mapping.get(self, "dev") diff --git a/api/models/oauth.py b/api/models/oauth.py index bd04d890d3..f17a4d3342 100644 --- a/api/models/oauth.py +++ b/api/models/oauth.py @@ -84,3 +84,39 @@ class DatasourceOauthTenantParamConfig(TypeBase): onupdate=func.current_timestamp(), init=False, ) + + +class OAuthAccessToken(TypeBase): + """Device-flow bearer. account_id NOT NULL ⇒ dfoa_ (Dify account, + subject_issuer = "dify:account" sentinel); account_id NULL + + subject_issuer = verified IdP issuer ⇒ dfoe_ (external SSO, EE-only). + subject_issuer is non-NULL for all rows the app writes — Postgres + treats NULLs as distinct in unique indices, so the partial unique + index on (subject_email, subject_issuer, client_id, device_label) + WHERE revoked_at IS NULL would otherwise fail to rotate in place. + """ + + __tablename__ = "oauth_access_tokens" + __table_args__ = (sa.PrimaryKeyConstraint("id", name="oauth_access_tokens_pkey"),) + + id: Mapped[str] = mapped_column( + StringUUID, insert_default=lambda: str(uuidv7()), default_factory=lambda: str(uuidv7()), init=False + ) + # Indexed text columns are bounded VARCHARs so the schema is portable + # across PostgreSQL and MySQL (MySQL cannot index TEXT without a prefix + # length). 255 chars accommodates RFC-compliant emails and typical + # OIDC issuer URLs / device labels. + subject_email: Mapped[str] = mapped_column(sa.String(255), nullable=False) + client_id: Mapped[str] = mapped_column(sa.String(64), nullable=False) + device_label: Mapped[str] = mapped_column(sa.String(255), nullable=False) + prefix: Mapped[str] = mapped_column(sa.String(8), nullable=False) + expires_at: Mapped[datetime] = mapped_column(sa.DateTime(timezone=True), nullable=False) + subject_issuer: Mapped[str | None] = mapped_column(sa.String(255), nullable=True, default=None) + account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + token_hash: Mapped[str | None] = mapped_column(sa.String(64), nullable=True, default=None) + last_used_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True, default=None) + revoked_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True, default=None) + + created_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=func.now(), init=False + ) diff --git a/api/models/workflow.py b/api/models/workflow.py index 7936c06a5a..282d4a8834 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1209,6 +1209,7 @@ class WorkflowAppLogCreatedFrom(StrEnum): SERVICE_API = "service-api" WEB_APP = "web-app" INSTALLED_APP = "installed-app" + OPENAPI = "openapi" @classmethod def value_of(cls, value: str) -> "WorkflowAppLogCreatedFrom": diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md new file mode 100644 index 0000000000..419acdca24 --- /dev/null +++ b/api/openapi/markdown/openapi-swagger.md @@ -0,0 +1,656 @@ +# OpenAPI +User-scoped programmatic API (bearer auth) + +## Version: 1.0 + +### Security +**Bearer** + +| apiKey | *API Key* | +| ------ | --------- | +| Description | Type: Bearer {your-api-key} | +| In | header | +| Name | Authorization | + +--- +## openapi +User-scoped operations + +### /_health + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /_version + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Server version | [ServerVersionResponse](#serverversionresponse) | + +### /account + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Account info | [AccountResponse](#accountresponse) | + +### /account/sessions + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Session list | [SessionListResponse](#sessionlistresponse) | + +### /account/sessions/self + +#### DELETE +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Session revoked | [RevokeResponse](#revokeresponse) | + +### /account/sessions/{session_id} + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| session_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Session revoked | [RevokeResponse](#revokeresponse) | + +### /apps + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| limit | query | | No | integer | +| mode | query | | No | string | +| name | query | | No | string | +| page | query | | No | integer | +| tag | query | | No | string | +| workspace_id | query | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App list | [AppListResponse](#applistresponse) | + +### /apps/{app_id}/describe + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| fields | query | | No | [ string ] | +| workspace_id | query | | No | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | App description | [AppDescribeResponse](#appdescriberesponse) | + +### /apps/{app_id}/files/upload + +#### POST +##### Description + +Upload a file to use as an input variable when running the app + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | File uploaded successfully | [FileResponse](#fileresponse) | +| 400 | Bad request — no file or filename missing | | +| 401 | Unauthorized — invalid or expired bearer token | | +| 413 | File too large | | +| 415 | Unsupported file type or blocked extension | | + +### /apps/{app_id}/form/human_input/{form_token} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| form_token | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Form definition | + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| form_token | path | | Yes | string | +| payload | body | | Yes | [HumanInputFormSubmitPayload](#humaninputformsubmitpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Form submitted | + +### /apps/{app_id}/run + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| payload | body | | Yes | [AppRunRequest](#apprunrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Run result (SSE stream) | + +### /apps/{app_id}/tasks/{task_id}/events + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | SSE event stream | + +### /apps/{app_id}/tasks/{task_id}/stop + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped | + +### /oauth/device/approve + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Approved | [DeviceMutateResponse](#devicemutateresponse) | + +### /oauth/device/code + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DeviceCodeRequest](#devicecoderequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Device code created | [DeviceCodeResponse](#devicecoderesponse) | + +### /oauth/device/deny + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DeviceMutateRequest](#devicemutaterequest) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Denied | [DeviceMutateResponse](#devicemutateresponse) | + +### /oauth/device/lookup + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| user_code | query | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Device lookup result | [DeviceLookupResponse](#devicelookupresponse) | + +### /oauth/device/token + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [DevicePollRequest](#devicepollrequest) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /permitted-external-apps + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) | + +### /workspaces + +#### GET +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) | + +### /workspaces/{workspace_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| workspace_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) | + +--- +### Models + +#### AccountPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | Yes | +| id | string | | Yes | +| name | string | | Yes | + +#### AccountResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account | [AccountPayload](#accountpayload) | | No | +| default_workspace_id | string | | No | +| subject_email | string | | No | +| subject_issuer | string | | No | +| subject_type | string | | Yes | +| workspaces | [ [WorkspacePayload](#workspacepayload) ] | | No | + +#### AppDescribeInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | No | +| description | string | | No | +| id | string | | Yes | +| is_agent | boolean | | No | +| mode | string | | Yes | +| name | string | | Yes | +| service_api_enabled | boolean | | Yes | +| tags | [ [TagItem](#tagitem) ] | | No | +| updated_at | string | | No | + +#### AppDescribeQuery + +`?fields=` allow-list for GET /apps//describe. + +Empty / omitted → all blocks. Unknown member → ValidationError → 422. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| fields | [ string ] | | No | +| workspace_id | string | | No | + +#### AppDescribeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| info | [AppDescribeInfo](#appdescribeinfo) | | No | +| input_schema | object | | No | +| parameters | object | | No | + +#### AppInfoResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author | string | | No | +| description | string | | No | +| id | string | | Yes | +| mode | string | | Yes | +| name | string | | Yes | +| tags | [ [TagItem](#tagitem) ] | | No | + +#### AppListQuery + +mode is a closed enum. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | No | +| mode | [AppMode](#appmode) | | No | +| name | string | | No | +| page | integer | | No | +| tag | string | | No | +| workspace_id | string | | Yes | + +#### AppListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AppListRow](#applistrow) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### AppListRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_by_name | string | | No | +| description | string | | No | +| id | string | | Yes | +| mode | [AppMode](#appmode) | | Yes | +| name | string | | Yes | +| tags | [ [TagItem](#tagitem) ] | | No | +| updated_at | string | | No | +| workspace_id | string | | No | +| workspace_name | string | | No | + +#### AppMode + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| AppMode | string | | | + +#### AppRunRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| auto_generate_name | boolean | | No | +| conversation_id | string | | No | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | No | +| workflow_id | string | | No | +| workspace_id | string | | No | + +#### DeviceCodeRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| device_label | string | | Yes | + +#### DeviceCodeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| device_code | string | | Yes | +| expires_in | integer | | Yes | +| interval | integer | | Yes | +| user_code | string | | Yes | +| verification_uri | string | | Yes | + +#### DeviceLookupQuery + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user_code | string | | Yes | + +#### DeviceLookupResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | No | +| expires_in_remaining | integer | | No | +| valid | boolean | | Yes | + +#### DeviceMutateRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| user_code | string | | Yes | + +#### DeviceMutateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | string | | Yes | + +#### DevicePollRequest + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| device_code | string | | Yes | + +#### FileResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_id | string | | No | +| created_at | integer | | No | +| created_by | string | | No | +| extension | string | | No | +| file_key | string | | No | +| id | string | | Yes | +| mime_type | string | | No | +| name | string | | Yes | +| original_url | string | | No | +| preview_url | string | | No | +| size | integer | | Yes | +| source_url | string | | No | +| tenant_id | string | | No | +| user_id | string | | No | + +#### HumanInputFormSubmitPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string | | Yes | +| inputs | object | | Yes | + +#### JsonValue + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| JsonValue | | | | + +#### MessageMetadata + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| retriever_resources | [ object ] | | No | +| usage | [UsageInfo](#usageinfo) | | No | + +#### PermittedExternalAppsListQuery + +Strict (extra='forbid'). + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | No | +| mode | [AppMode](#appmode) | | No | +| name | string | | No | +| page | integer | | No | + +#### PermittedExternalAppsListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [AppListRow](#applistrow) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### RevokeResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| status | string | | Yes | + +#### ServerVersionResponse + +Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| edition | string | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes | +| version | string | | Yes | + +#### SessionListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [SessionRow](#sessionrow) ] | | Yes | +| has_more | boolean | | Yes | +| limit | integer | | Yes | +| page | integer | | Yes | +| total | integer | | Yes | + +#### SessionRow + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| client_id | string | | Yes | +| created_at | string | | No | +| device_label | string | | Yes | +| expires_at | string | | No | +| id | string | | Yes | +| last_used_at | string | | No | +| prefix | string | | Yes | + +#### TagItem + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | | Yes | + +#### UsageInfo + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completion_tokens | integer | | No | +| prompt_tokens | integer | | No | +| total_tokens | integer | | No | + +#### WorkflowRunData + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | integer | | No | +| elapsed_time | number | | No | +| error | string | | No | +| finished_at | integer | | No | +| id | string | | Yes | +| outputs | object | | No | +| status | string | | Yes | +| total_steps | integer | | No | +| total_tokens | integer | | No | +| workflow_id | string | | Yes | + +#### WorkspaceDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | string | | No | +| current | boolean | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| role | string | | Yes | +| status | string | | Yes | + +#### WorkspaceListResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| workspaces | [ [WorkspaceSummaryResponse](#workspacesummaryresponse) ] | | Yes | + +#### WorkspacePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | Yes | +| name | string | | Yes | +| role | string | | Yes | + +#### WorkspaceSummaryResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| current | boolean | | Yes | +| id | string | | Yes | +| name | string | | Yes | +| role | string | | Yes | +| status | string | | Yes | diff --git a/api/schedule/clean_oauth_access_tokens_task.py b/api/schedule/clean_oauth_access_tokens_task.py new file mode 100644 index 0000000000..10250e986e --- /dev/null +++ b/api/schedule/clean_oauth_access_tokens_task.py @@ -0,0 +1,54 @@ +"""DELETE oauth_access_tokens past retention. Revocation is UPDATE +(token_id stays for audits) so rows accumulate across re-logins, and +expired-but-never-presented rows have no hard-expire trigger — both get +pruned here. Spec: docs/specs/v1.0/server/tokens.md §Hard-expire. +""" + +from __future__ import annotations + +import logging +import time +from datetime import UTC, datetime, timedelta + +import click +from sqlalchemy import delete, or_, select + +import app +from configs import dify_config +from extensions.ext_database import db +from models.oauth import OAuthAccessToken + +logger = logging.getLogger(__name__) + +DELETE_BATCH_SIZE = 500 + + +@app.celery.task(queue="retention") +def clean_oauth_access_tokens_task(): + click.echo(click.style("Start clean oauth_access_tokens.", fg="green")) + retention_days = int(dify_config.OAUTH_ACCESS_TOKEN_RETENTION_DAYS) + cutoff = datetime.now(UTC) - timedelta(days=retention_days) + start_at = time.perf_counter() + + candidates = or_( + OAuthAccessToken.revoked_at < cutoff, + # Zombies: expired but never re-presented, so middleware never flipped them. + (OAuthAccessToken.revoked_at.is_(None)) & (OAuthAccessToken.expires_at < cutoff), + ) + + total = 0 + while True: + ids = db.session.scalars(select(OAuthAccessToken.id).where(candidates).limit(DELETE_BATCH_SIZE)).all() + if not ids: + break + db.session.execute(delete(OAuthAccessToken).where(OAuthAccessToken.id.in_(ids))) + db.session.commit() + total += len(ids) + + end_at = time.perf_counter() + click.echo( + click.style( + f"Cleaned {total} oauth_access_tokens rows older than {retention_days}d in {end_at - start_at:.2f}s", + fg="green", + ) + ) diff --git a/api/services/account_service.py b/api/services/account_service.py index e020831180..344b3619f2 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -8,7 +8,8 @@ from hashlib import sha256 from typing import Any, TypedDict, cast from pydantic import BaseModel, TypeAdapter, ValidationError -from sqlalchemy import delete, func, select, update +from sqlalchemy import Row, delete, func, select, update +from sqlalchemy.orm import Session, scoped_session from core.db.session_factory import session_factory @@ -163,6 +164,41 @@ class AccountService: redis_client.delete(AccountService._get_refresh_token_key(refresh_token)) redis_client.delete(AccountService._get_account_refresh_token_key(account_id)) + @staticmethod + def get_account_by_email(session: Session | scoped_session, email: str) -> Account | None: + """Plain ``Account`` getter keyed by email. Case-sensitive — use + :meth:`has_active_account_with_email` for the case-insensitive + existence check that backs the SSO collision rule. + """ + return session.execute(select(Account).where(Account.email == email)).scalar_one_or_none() + + @staticmethod + def has_active_account_with_email(session: Session | scoped_session, email: str) -> bool: + if not email: + return False + normalized = email.strip().lower() + if not normalized: + return False + row = session.execute( + select(Account.id).where( + func.lower(Account.email) == normalized, + Account.status == AccountStatus.ACTIVE, + ) + ).scalar_one_or_none() + return row is not None + + @staticmethod + def get_account_by_id(session: Session | scoped_session, account_id: str) -> Account | None: + """Plain ``Account`` getter — no banned check, no tenant rotation, + no ``last_active_at`` write. Use this from read-only identity + endpoints (``/openapi/v1/account``) where ``load_user``'s + side-effects (current-tenant assignment, commit) are unwanted. + + ``session`` is injected by the caller so this service stays free + of the Flask-scoped ``db.session`` import. + """ + return session.get(Account, account_id) + @staticmethod def load_user(user_id: str) -> None | Account: account = db.session.get(Account, user_id) @@ -1182,6 +1218,127 @@ class TenantService: ).all() ) + @staticmethod + def get_account_memberships( + session: Session | scoped_session, + account_id: str, + ) -> list[Row[tuple[TenantAccountJoin, Tenant]]]: + """Return ``(TenantAccountJoin, Tenant)`` rows for every workspace + the account belongs to. Unlike :meth:`get_join_tenants` this keeps + the join row so callers can read ``role``/``current`` alongside the + tenant — used by ``/openapi/v1/account`` to render workspace + membership + pick the default workspace. + + ``session`` is injected by the caller so this service stays free + of the Flask-scoped ``db.session`` import. + + No tenant-status filter: parity with the legacy controller query + (the openapi identity endpoint listed all joined tenants). + """ + return ( + session.query(TenantAccountJoin, Tenant) + .join(Tenant, Tenant.id == TenantAccountJoin.tenant_id) + .filter(TenantAccountJoin.account_id == account_id) + .all() + ) + + @staticmethod + def get_workspaces_for_account( + session: Session | scoped_session, + account_id: str, + ) -> list[Row[tuple[Tenant, TenantAccountJoin]]]: + """``(Tenant, TenantAccountJoin)`` rows for every workspace the + account belongs to, ordered by ``Tenant.created_at`` ASC — the + canonical ordering for ``/openapi/v1/workspaces``. + + Distinct from :meth:`get_account_memberships`: tuple order is + flipped (tenant first) and rows are sorted, so the workspace + listing is stable across requests. + """ + return list( + session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id) + .where(TenantAccountJoin.account_id == account_id) + .order_by(Tenant.created_at.asc()) + ).all() + ) + + @staticmethod + def account_belongs_to_tenant( + session: Session | scoped_session, + account_id: uuid.UUID | str | None, + tenant_id: str, + ) -> bool: + """Existence check for ``TenantAccountJoin(account_id, tenant_id)``. + Backs the CE-deployment membership fallback in + ``controllers.openapi.auth.strategies.MembershipStrategy``. + + ``None``/empty ``account_id`` short-circuits to ``False`` so SSO + bearers (no account) and missing identity collapse cleanly. + """ + if not account_id: + return False + row = session.execute( + select(TenantAccountJoin.id).where( + TenantAccountJoin.tenant_id == tenant_id, + TenantAccountJoin.account_id == account_id, + ) + ).scalar_one_or_none() + return row is not None + + @staticmethod + def get_tenant_by_id(session: Session | scoped_session, tenant_id: str) -> Tenant | None: + """Plain ``session.get(Tenant, tenant_id)`` — no status filter. + Callers map ``status == ARCHIVE`` to their own error code (the + openapi auth pipeline raises 403 ``workspace unavailable``). + """ + return session.get(Tenant, tenant_id) + + @staticmethod + def get_tenants_by_ids( + session: Session | scoped_session, + tenant_ids: list[str], + ) -> list[Tenant]: + """Bulk ``Tenant`` fetch by primary-key list. Order is unspecified + — callers index by ``tenant.id`` (e.g. for cross-tenant denorm + in ``/openapi/v1/permitted-external-apps``). + + Empty input short-circuits to ``[]`` to avoid emitting an + ``IN ()`` SQL fragment. + """ + if not tenant_ids: + return [] + return list(session.execute(select(Tenant).where(Tenant.id.in_(tenant_ids))).scalars().all()) + + @staticmethod + def get_tenant_name(session: Session | scoped_session, tenant_id: str) -> str | None: + """Single-column tenant name read. Used by openapi list endpoints + to denormalize ``workspace_name`` onto each row without dragging + the full ``Tenant`` ORM entity through. + """ + return session.execute(select(Tenant.name).where(Tenant.id == tenant_id)).scalar_one_or_none() + + @staticmethod + def find_workspace_for_account( + session: Session | scoped_session, + account_id: str, + workspace_id: str, + ) -> Row[tuple[Tenant, TenantAccountJoin]] | None: + """Single ``(Tenant, TenantAccountJoin)`` row scoped to the + account's membership in ``workspace_id``. ``None`` on non-member + — the caller maps that to 404 (not 403) so workspace IDs don't + leak across tenants via response codes. + """ + return session.execute( + select(Tenant, TenantAccountJoin) + .join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id) + .where( + Tenant.id == workspace_id, + TenantAccountJoin.account_id == account_id, + ) + ).first() + @staticmethod def get_current_tenant_by_account(account: Account): """Get tenant by account and add the role""" diff --git a/api/services/app_service.py b/api/services/app_service.py index 6716833f6c..bc867e8dc4 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -1,11 +1,13 @@ import json import logging +from collections.abc import Sequence from typing import Any, Literal, TypedDict, cast import sqlalchemy as sa from flask_sqlalchemy.pagination import Pagination from pydantic import BaseModel, Field from sqlalchemy import select +from sqlalchemy.orm import Session, scoped_session from configs import dify_config from constants.model_template import default_app_templates @@ -26,6 +28,7 @@ from models.tools import ApiToolProvider from services.billing_service import BillingService from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService +from services.openapi.visibility import apply_openapi_gate, is_openapi_visible from services.tag_service import TagService from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task @@ -39,6 +42,8 @@ class AppListParams(BaseModel): name: str | None = None tag_ids: list[str] | None = None is_created_by_me: bool | None = None + status: str | None = None + openapi_visible: bool = False class CreateAppParams(BaseModel): @@ -54,6 +59,51 @@ class CreateAppParams(BaseModel): class AppService: + @staticmethod + def get_app_by_id( + session: Session | scoped_session, + app_id: str, + ) -> App | None: + return session.get(App, app_id) + + @staticmethod + def get_visible_app_by_id( + session: Session | scoped_session, + app_id: str, + ) -> App | None: + app = session.get(App, app_id) + if not app or app.status != "normal" or not is_openapi_visible(app): + return None + return app + + @staticmethod + def find_visible_apps_by_ids( + session: Session | scoped_session, + app_ids: Sequence[str], + ) -> list[App]: + if not app_ids: + return [] + return list(session.execute(apply_openapi_gate(select(App).where(App.id.in_(list(app_ids))))).scalars().all()) + + @staticmethod + def find_visible_apps_by_name( + session: Session | scoped_session, + *, + name: str, + tenant_id: str, + ) -> list[App]: + return list( + session.execute( + apply_openapi_gate( + select(App).where( + App.name == name, + App.tenant_id == tenant_id, + App.status == "normal", + ) + ) + ).scalars() + ) + def get_paginate_apps(self, user_id: str, tenant_id: str, params: AppListParams) -> Pagination | None: """ Get app list with pagination @@ -75,6 +125,14 @@ class AppService: elif params.mode == "agent-chat": filters.append(App.mode == AppMode.AGENT_CHAT) + if params.status: + filters.append(App.status == params.status) + # OpenAPI surface visibility gate. Pushed into the query so + # `pagination.total` reflects only apps the openapi caller can + # actually reach — post-filtering by enable_api after the page + # arrives would make `total` page-dependent. + if params.openapi_visible: + filters.append(App.enable_api.is_(True)) if params.is_created_by_me: filters.append(App.created_by == user_id) if params.name: diff --git a/api/services/enterprise/app_permitted_service.py b/api/services/enterprise/app_permitted_service.py new file mode 100644 index 0000000000..77d6346995 --- /dev/null +++ b/api/services/enterprise/app_permitted_service.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from werkzeug.exceptions import ServiceUnavailable + +from services.enterprise.enterprise_service import EnterpriseService +from services.errors.enterprise import EnterpriseAPIError + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class PermittedAppsPage: + app_ids: list[str] + total: int + has_more: bool + + +def list_permitted_apps( + *, + page: int, + limit: int, + mode: str | None = None, + name: str | None = None, +) -> PermittedAppsPage: + try: + body = EnterpriseService.WebAppAuth.list_externally_accessible_apps( + page=page, limit=limit, mode=mode, name=name + ) + except EnterpriseAPIError as exc: + logger.warning( + "permitted_apps EE call failed: status=%s message=%s", + getattr(exc, "status_code", None), + str(exc), + ) + raise ServiceUnavailable("permitted_apps_unavailable") from exc + + return PermittedAppsPage( + app_ids=[row["appId"] for row in body.get("data", [])], + total=int(body.get("total", 0)), + has_more=bool(body.get("hasMore", False)), + ) diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index bd7758f1c0..3666d11fb4 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import logging import uuid from datetime import datetime @@ -24,10 +25,22 @@ VALID_LICENSE_CACHE_TTL = 600 # 10 minutes — valid licenses are stable INVALID_LICENSE_CACHE_TTL = 30 # 30 seconds — short so admin fixes are picked up quickly +class WebAppAccessMode(enum.StrEnum): + PUBLIC = "public" + PRIVATE = "private" + PRIVATE_ALL = "private_all" + SSO_VERIFIED = "sso_verified" + + +PERMISSION_CHECK_MODES: frozenset[WebAppAccessMode] = frozenset( + {WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL} +) + + class WebAppSettings(BaseModel): access_mode: str = Field( - description="Access mode for the web app. Can be 'public', 'private', 'private_all', 'sso_verified'", - default="private", + description=f"Access mode for the web app. One of: {', '.join(m.value for m in WebAppAccessMode)}", + default=WebAppAccessMode.PRIVATE.value, alias="accessMode", ) @@ -108,6 +121,15 @@ class EnterpriseService: def get_workspace_info(cls, tenant_id: str): return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info") + @classmethod + def initiate_device_flow_sso(cls, signed_state: str) -> dict: + return EnterpriseRequest.send_request( + "POST", + "/device-flow/sso-initiate", + json={"signed_state": signed_state}, + raise_for_status=True, + ) + @classmethod def join_default_workspace(cls, *, account_id: str) -> DefaultWorkspaceJoinResult: """ @@ -219,8 +241,9 @@ class EnterpriseService: def update_app_access_mode(cls, app_id: str, access_mode: str): if not app_id: raise ValueError("app_id must be provided.") - if access_mode not in ["public", "private", "private_all"]: - raise ValueError("access_mode must be either 'public', 'private', or 'private_all'") + allowed = {WebAppAccessMode.PUBLIC, WebAppAccessMode.PRIVATE, WebAppAccessMode.PRIVATE_ALL} + if access_mode not in allowed: + raise ValueError(f"access_mode must be one of: {', '.join(m.value for m in allowed)}") data = {"appId": app_id, "accessMode": access_mode} @@ -236,6 +259,32 @@ class EnterpriseService: params = {"appId": app_id} EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params) + @classmethod + def list_externally_accessible_apps( + cls, + *, + page: int, + limit: int, + mode: str | None = None, + name: str | None = None, + ) -> dict: + """Call EE InnerListExternallyAccessibleApps; returns raw camelCase response. + + Response shape: ``{"data": [{"appId", "tenantId", "mode", "name", "updatedAt"}], + "total": int, "hasMore": bool}``. + """ + body: dict[str, str | int] = {"page": page, "limit": limit} + if mode is not None: + body["mode"] = mode + if name is not None: + body["name"] = name + return EnterpriseRequest.send_request( + "POST", + "/webapp/externally-accessible-apps", + json=body, + timeout=5.0, + ) + @classmethod def get_cached_license_status(cls) -> LicenseStatus | None: """Get enterprise license status with Redis caching to reduce HTTP calls. diff --git a/api/services/oauth_device_flow.py b/api/services/oauth_device_flow.py new file mode 100644 index 0000000000..d11c5292ad --- /dev/null +++ b/api/services/oauth_device_flow.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import os +import secrets +import time +import uuid +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from typing import Any, NotRequired, TypedDict + +from sqlalchemy import and_, func, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session, scoped_session + +from libs.oauth_bearer import TOKEN_CACHE_KEY_FMT, AuthContext, SubjectType +from models.oauth import OAuthAccessToken + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Redis state machine — device_code + user_code ephemeral state +# ============================================================================ + + +_DEVICE_CODE_KEY_PREFIX = "device_code:" +_USER_CODE_KEY_PREFIX = "user_code:" +DEVICE_CODE_KEY_FMT = _DEVICE_CODE_KEY_PREFIX + "{code}" +USER_CODE_KEY_FMT = _USER_CODE_KEY_PREFIX + "{code}" + +# Atomic GET → status-check → DEL(both keys). Two concurrent pollers must +# not both observe APPROVED — only the winner gets the plaintext token, +# the loser sees nil and the caller maps that to expired_token. +_CONSUME_ON_POLL_LUA = """ +local raw = redis.call('GET', KEYS[1]) +if not raw then return nil end +local ok, decoded = pcall(cjson.decode, raw) +if not ok then return nil end +if decoded.status == 'pending' then return nil end +if decoded.user_code then + redis.call('DEL', ARGV[1] .. decoded.user_code) +end +redis.call('DEL', KEYS[1]) +return raw +""" + +DEVICE_FLOW_TTL_SECONDS = 15 * 60 # RFC 8628 expires_in +APPROVED_TTL_SECONDS_MIN = 60 # plaintext-token lifetime floor + +USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXY3456789" # ambiguous chars dropped +USER_CODE_SEGMENT_LEN = 4 +USER_CODE_MAX_CLAIM_ATTEMPTS = 5 + +DEFAULT_POLL_INTERVAL_SECONDS = 5 # RFC 8628 minimum + + +class DeviceFlowStatus(StrEnum): + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + +class SlowDownDecision(StrEnum): + OK = "ok" + SLOW_DOWN = "slow_down" + + +class PollPayload(TypedDict): + """Body served by the unauthenticated poll endpoint + (`POST /openapi/v1/oauth/device/token`) once approve has run. + + A single shape across both branches so the CLI/SPA can parse one + contract: + + - ``account`` branch (built in `controllers.openapi.oauth_device. + _build_account_poll_payload`) populates ``account`` + ``workspaces`` + + ``default_workspace_id`` and omits the SSO-only fields. + - ``external_sso`` branch (built in + `controllers.openapi.oauth_device_sso.approve_external`) populates + ``subject_email`` + ``subject_issuer`` and zero-fills the + account/workspace fields (``None`` / ``[]``). + + Pre-rendering here means the unauthenticated poll handler doesn't + re-query accounts/tenants for authz data. + """ + + token: str + expires_at: str + subject_type: SubjectType + account: dict[str, object] | None + workspaces: list[dict[str, object]] + default_workspace_id: str | None + token_id: str + subject_email: NotRequired[str] + subject_issuer: NotRequired[str] + + +@dataclass +class DeviceFlowState: + """``minted_token`` is plaintext between approve and the next poll; + DEL'd after the poll reads it. + """ + + user_code: str + client_id: str + device_label: str + status: DeviceFlowStatus + subject_email: str | None = None + account_id: str | None = None + subject_issuer: str | None = None + minted_token: str | None = None + token_id: str | None = None + created_at: str = "" + created_ip: str = "" + last_poll_at: str = "" + poll_payload: PollPayload | None = field(default=None) + + def to_json(self) -> str: + return json.dumps(asdict(self)) + + @classmethod + def from_json(cls, raw: str) -> DeviceFlowState: + data = json.loads(raw) + if "status" in data: + data["status"] = DeviceFlowStatus(data["status"]) + return cls(**data) + + +def _random_device_code() -> str: + return "dc_" + secrets.token_urlsafe(24) + + +def _random_user_code_segment() -> str: + return "".join(secrets.choice(USER_CODE_ALPHABET) for _ in range(USER_CODE_SEGMENT_LEN)) + + +def _random_user_code() -> str: + return f"{_random_user_code_segment()}-{_random_user_code_segment()}" + + +class StateNotFoundError(Exception): + pass + + +class InvalidTransitionError(Exception): + pass + + +class UserCodeExhaustedError(Exception): + pass + + +class DeviceFlowRedis: + def __init__(self, redis_client) -> None: + self._redis = redis_client + self._consume_on_poll_script = redis_client.register_script(_CONSUME_ON_POLL_LUA) + + def start(self, client_id: str, device_label: str, created_ip: str) -> tuple[str, str, int]: + device_code = _random_device_code() + user_code = self._claim_user_code(device_code) + state = DeviceFlowState( + user_code=user_code, + client_id=client_id, + device_label=device_label, + status=DeviceFlowStatus.PENDING, + created_at=datetime.now(UTC).isoformat(), + created_ip=created_ip, + ) + self._redis.setex( + DEVICE_CODE_KEY_FMT.format(code=device_code), + DEVICE_FLOW_TTL_SECONDS, + state.to_json(), + ) + return device_code, user_code, DEVICE_FLOW_TTL_SECONDS + + def _claim_user_code(self, device_code: str) -> str: + for _ in range(USER_CODE_MAX_CLAIM_ATTEMPTS): + user_code = _random_user_code() + key = USER_CODE_KEY_FMT.format(code=user_code) + ok = self._redis.set(key, device_code, nx=True, ex=DEVICE_FLOW_TTL_SECONDS) + if ok: + return user_code + raise UserCodeExhaustedError("could not allocate a unique user_code in 5 attempts") + + def load_by_user_code(self, user_code: str) -> tuple[str, DeviceFlowState] | None: + raw_dc = self._redis.get(USER_CODE_KEY_FMT.format(code=user_code)) + if not raw_dc: + return None + device_code = raw_dc.decode() if isinstance(raw_dc, (bytes, bytearray)) else raw_dc + state = self._load_state(device_code) + if state is None: + return None + return device_code, state + + def load_by_device_code(self, device_code: str) -> DeviceFlowState | None: + return self._load_state(device_code) + + def _load_state(self, device_code: str) -> DeviceFlowState | None: + raw = self._redis.get(DEVICE_CODE_KEY_FMT.format(code=device_code)) + if not raw: + return None + text_ = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw + try: + return DeviceFlowState.from_json(text_) + except (ValueError, KeyError): + logger.exception("device_flow: corrupt state for %s", device_code) + return None + + def approve( + self, + device_code: str, + subject_email: str, + account_id: str | None, + minted_token: str, + token_id: str, + subject_issuer: str | None = None, + poll_payload: PollPayload | None = None, + ) -> None: + state = self._load_state(device_code) + if state is None: + raise StateNotFoundError(device_code) + if state.status is not DeviceFlowStatus.PENDING: + raise InvalidTransitionError(f"cannot approve {state.status}") + + state.status = DeviceFlowStatus.APPROVED + state.subject_email = subject_email + state.account_id = account_id + state.subject_issuer = subject_issuer + state.minted_token = minted_token + state.token_id = token_id + state.poll_payload = poll_payload + + new_ttl = self._remaining_ttl(device_code, floor=APPROVED_TTL_SECONDS_MIN) + self._redis.setex(DEVICE_CODE_KEY_FMT.format(code=device_code), new_ttl, state.to_json()) + + def deny(self, device_code: str) -> None: + state = self._load_state(device_code) + if state is None: + raise StateNotFoundError(device_code) + if state.status is not DeviceFlowStatus.PENDING: + raise InvalidTransitionError(f"cannot deny {state.status}") + state.status = DeviceFlowStatus.DENIED + self._redis.setex( + DEVICE_CODE_KEY_FMT.format(code=device_code), + self._remaining_ttl(device_code, floor=1), + state.to_json(), + ) + + def consume_on_poll(self, device_code: str) -> DeviceFlowState | None: + """Race-safe via Lua EVAL: GET + status-check + DEL execute in a + single Redis transaction so only one of N concurrent pollers + observes the APPROVED state. Losers get None, mapped to + expired_token by the caller. + """ + raw = self._consume_on_poll_script( + keys=[DEVICE_CODE_KEY_FMT.format(code=device_code)], + args=[_USER_CODE_KEY_PREFIX], + ) + if raw is None: + return None + text_ = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw + try: + return DeviceFlowState.from_json(text_) + except (ValueError, KeyError): + logger.exception("device_flow: corrupt state on consume %s", device_code) + return None + + def record_poll(self, device_code: str, interval_seconds: int) -> SlowDownDecision: + now = time.time() + key = f"device_code:{device_code}:last_poll" + prev_raw = self._redis.get(key) + self._redis.setex(key, DEVICE_FLOW_TTL_SECONDS, str(now)) + if prev_raw is None: + return SlowDownDecision.OK + prev_s = prev_raw.decode() if isinstance(prev_raw, (bytes, bytearray)) else prev_raw + try: + prev = float(prev_s) + except ValueError: + return SlowDownDecision.OK + if now - prev < interval_seconds: + return SlowDownDecision.SLOW_DOWN + return SlowDownDecision.OK + + def _remaining_ttl(self, device_code: str, floor: int) -> int: + """``max(remaining, floor)`` — guarantees the CLI has at least + ``floor`` seconds to poll after a near-expiry approve. + """ + ttl = self._redis.ttl(DEVICE_CODE_KEY_FMT.format(code=device_code)) + if ttl is None or ttl < 0: + return floor + return max(int(ttl), floor) + + +# ============================================================================ +# Token mint — generate + upsert +# ============================================================================ + + +OAUTH_BODY_BYTES = 32 # ~256 bits entropy +PREFIX_OAUTH_ACCOUNT = "dfoa_" +PREFIX_OAUTH_EXTERNAL_SSO = "dfoe_" + +# Sentinel issuer for account-flow rows. Postgres' default partial unique +# index treats NULLs as distinct, which would let two live `dfoa_` rows +# share (email, client, device) and break rotate-in-place. Storing a +# non-empty literal makes the composite key collide as intended. +ACCOUNT_ISSUER_SENTINEL = "dify:account" + + +@dataclass(frozen=True, slots=True) +class MintResult: + """Plaintext token surfaces to the caller once.""" + + token: str + token_id: uuid.UUID + expires_at: datetime + + +@dataclass(frozen=True, slots=True) +class UpsertOutcome: + token_id: uuid.UUID + rotated: bool + old_hash: str | None + + +def generate_token(prefix: str) -> str: + return prefix + secrets.token_urlsafe(OAUTH_BODY_BYTES) + + +def sha256_hex(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def mint_oauth_token( + # Accept either Session or Flask-SQLAlchemy's request-scoped wrapper — + # the wrapper proxies the same execute/commit surface. + session: Session | scoped_session, + redis_client, + *, + subject_email: str, + subject_issuer: str | None, + account_id: str | None, + client_id: str, + device_label: str, + prefix: str, + ttl_days: int, +) -> MintResult: + """Live row rotates in place via partial unique index + ``uq_oauth_active_per_device``; hard-expired rows are excluded by the + index predicate so re-login INSERTs fresh. Pre-rotate Redis entry is + deleted so stale AuthContext drops immediately. + """ + if prefix == PREFIX_OAUTH_ACCOUNT: + # Account flow always writes the sentinel — caller may pass None + # (for clarity) or the sentinel itself; nothing else is valid. + if subject_issuer not in (None, ACCOUNT_ISSUER_SENTINEL): + raise ValueError(f"account-flow token must use ACCOUNT_ISSUER_SENTINEL, got {subject_issuer!r}") + subject_issuer = ACCOUNT_ISSUER_SENTINEL + elif prefix == PREFIX_OAUTH_EXTERNAL_SSO: + # Defense in depth: enterprise canonicalises + rejects empty, + # but a regression there must not yield a NULL composite key here. + if not subject_issuer or not subject_issuer.strip(): + raise ValueError("external-SSO token requires non-empty subject_issuer") + else: + raise ValueError(f"unknown oauth prefix: {prefix!r}") + + token = generate_token(prefix) + new_hash = sha256_hex(token) + expires_at = datetime.now(UTC) + timedelta(days=ttl_days) + + outcome = _upsert( + session, + subject_email=subject_email, + subject_issuer=subject_issuer, + account_id=account_id, + client_id=client_id, + device_label=device_label, + prefix=prefix, + new_hash=new_hash, + expires_at=expires_at, + ) + + if outcome.rotated and outcome.old_hash: + redis_client.delete(TOKEN_CACHE_KEY_FMT.format(hash=outcome.old_hash)) + + return MintResult(token=token, token_id=outcome.token_id, expires_at=expires_at) + + +def _upsert( + session: Session | scoped_session, + *, + subject_email: str, + subject_issuer: str | None, + account_id: str | None, + client_id: str, + device_label: str, + prefix: str, + new_hash: str, + expires_at: datetime, +) -> UpsertOutcome: + # Snapshot prior live row's hash for Redis invalidation post-rotate. + # subject_issuer is always non-null here (account flow uses sentinel, + # external-SSO is validated upstream), so equality matches the index. + prior = session.execute( + select(OAuthAccessToken.id, OAuthAccessToken.token_hash) + .where( + OAuthAccessToken.subject_email == subject_email, + OAuthAccessToken.subject_issuer == subject_issuer, + OAuthAccessToken.client_id == client_id, + OAuthAccessToken.device_label == device_label, + OAuthAccessToken.revoked_at.is_(None), + ) + .limit(1) + ).first() + old_hash = prior.token_hash if prior else None + + insert_stmt = pg_insert(OAuthAccessToken).values( + subject_email=subject_email, + subject_issuer=subject_issuer, + account_id=account_id, + client_id=client_id, + device_label=device_label, + prefix=prefix, + token_hash=new_hash, + expires_at=expires_at, + ) + upsert_stmt = insert_stmt.on_conflict_do_update( + index_elements=["subject_email", "subject_issuer", "client_id", "device_label"], + index_where=OAuthAccessToken.revoked_at.is_(None), + set_={ + "token_hash": insert_stmt.excluded.token_hash, + "prefix": insert_stmt.excluded.prefix, + "account_id": insert_stmt.excluded.account_id, + "expires_at": insert_stmt.excluded.expires_at, + "created_at": func.now(), + "last_used_at": None, + }, + ).returning(OAuthAccessToken.id) + row = session.execute(upsert_stmt).first() + session.commit() + + if row is None: + raise RuntimeError("oauth_token upsert returned no row") + token_id = uuid.UUID(str(row.id)) + return UpsertOutcome( + token_id=token_id, + rotated=prior is not None, + old_hash=old_hash, + ) + + +# ============================================================================ +# TTL policy — days new OAuth tokens live +# ============================================================================ + + +DEFAULT_OAUTH_TTL_DAYS = 14 +MIN_TTL_DAYS = 1 +MAX_TTL_DAYS = 365 + +_TTL_ENV_VAR = "OAUTH_TTL_DAYS" + + +def oauth_ttl_days(tenant_id: str | None = None) -> int: + """``OAUTH_TTL_DAYS`` env, else default. EE tenant-level lookup + is deferred; when it lands it wins over the env (Redis-cached 60s). + """ + _ = tenant_id + + raw = os.environ.get(_TTL_ENV_VAR) + if raw is None: + return DEFAULT_OAUTH_TTL_DAYS + try: + value = int(raw) + except ValueError: + logger.warning( + "%s=%r is not an int; falling back to %d", + _TTL_ENV_VAR, + raw, + DEFAULT_OAUTH_TTL_DAYS, + ) + return DEFAULT_OAUTH_TTL_DAYS + if value < MIN_TTL_DAYS: + logger.warning("%s=%d below min %d; clamping", _TTL_ENV_VAR, value, MIN_TTL_DAYS) + return MIN_TTL_DAYS + if value > MAX_TTL_DAYS: + logger.warning("%s=%d above max %d; clamping", _TTL_ENV_VAR, value, MAX_TTL_DAYS) + return MAX_TTL_DAYS + return value + + +def subject_match_clauses(ctx: AuthContext) -> tuple[Any, ...]: + if ctx.subject_type == SubjectType.ACCOUNT: + return (OAuthAccessToken.account_id == str(ctx.account_id),) + return ( + OAuthAccessToken.subject_email == ctx.subject_email, + OAuthAccessToken.subject_issuer == ctx.subject_issuer, + OAuthAccessToken.account_id.is_(None), + ) + + +def list_active_sessions( + session: Session | scoped_session, + ctx: AuthContext, + now: datetime, +) -> list[OAuthAccessToken]: + return list( + session.execute( + select(OAuthAccessToken) + .where( + and_( + *subject_match_clauses(ctx), + OAuthAccessToken.revoked_at.is_(None), + OAuthAccessToken.token_hash.is_not(None), + OAuthAccessToken.expires_at > now, + ) + ) + .order_by(OAuthAccessToken.created_at.desc()) + ) + .scalars() + .all() + ) + + +def token_belongs_to_subject( + session: Session | scoped_session, + token_id: str, + ctx: AuthContext, +) -> bool: + row = session.execute( + select(OAuthAccessToken.id).where( + and_( + OAuthAccessToken.id == token_id, + *subject_match_clauses(ctx), + ) + ) + ).first() + return row is not None + + +def revoke_oauth_token( + session: Session | scoped_session, + redis_client: Any, + token_id: str, +) -> None: + row = ( + session.query(OAuthAccessToken.token_hash) + .filter( + OAuthAccessToken.id == token_id, + OAuthAccessToken.revoked_at.is_(None), + ) + .one_or_none() + ) + pre_revoke_hash = row[0] if row else None + + stmt = ( + update(OAuthAccessToken) + .where( + OAuthAccessToken.id == token_id, + OAuthAccessToken.revoked_at.is_(None), + ) + .values(revoked_at=datetime.now(UTC), token_hash=None) + ) + session.execute(stmt) + session.commit() + + if pre_revoke_hash: + redis_client.delete(TOKEN_CACHE_KEY_FMT.format(hash=pre_revoke_hash)) diff --git a/api/services/openapi/__init__.py b/api/services/openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/services/openapi/license_gate.py b/api/services/openapi/license_gate.py new file mode 100644 index 0000000000..c4f17e12c0 --- /dev/null +++ b/api/services/openapi/license_gate.py @@ -0,0 +1,52 @@ +"""License gate for the /openapi/v1/permitted-external-apps* surface. + +EE-only. CE deploys (``ENTERPRISE_ENABLED=false``) skip the gate entirely — +the EE blueprint chain is what gives CE deploys no callers on this surface +in practice, but the explicit short-circuit avoids any test/fixture that +flips the surface on without flipping the license. + +Reuses ``FeatureService.get_system_features()`` so the license status +travels the same path as the console reads. + +Companion to ``controllers.console.wraps.enterprise_license_required`` — +that one is for console (cookie-authed, force-logout 401). This one is +for bearer surface (token-authed, 403 ``license_required``). +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from functools import wraps + +from werkzeug.exceptions import Forbidden + +from configs import dify_config +from services.feature_service import FeatureService, LicenseStatus + +logger = logging.getLogger(__name__) + +_VALID_LICENSE_STATUSES: frozenset[LicenseStatus] = frozenset({LicenseStatus.ACTIVE, LicenseStatus.EXPIRING}) + + +def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: + """Decorator form. Raises ``Forbidden('license_required')`` when the EE + deployment has no valid license. No-op on CE (``ENTERPRISE_ENABLED=false``). + """ + + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + if dify_config.ENTERPRISE_ENABLED and not _is_license_valid(): + raise Forbidden(description="license_required") + return view(*args, **kwargs) + + return decorated + + +def _is_license_valid() -> bool: + try: + features = FeatureService.get_system_features() + except Exception: + logger.exception("license_gate: FeatureService.get_system_features failed") + return False + return features.license.status in _VALID_LICENSE_STATUSES diff --git a/api/services/openapi/mint_policy.py b/api/services/openapi/mint_policy.py new file mode 100644 index 0000000000..303ecc3209 --- /dev/null +++ b/api/services/openapi/mint_policy.py @@ -0,0 +1,47 @@ +"""Hard mint policy. + +``validate_mint_policy`` cross-checks a (subject_type, prefix, scopes) +triple a caller intends to mint against ``MINTABLE_PROFILES`` — +the single source of truth in ``libs.oauth_bearer``. + +The defense-in-depth value: if a future caller assembles ``prefix`` or +``scopes`` from a non-canonical source (env, request body, plug-in +contribution), the mismatch fails closed at approve time before any +row hits the DB. When the caller reads straight from +``MINTABLE_PROFILES``, the check is a structural pin — it confirms the +table entry is well-formed and the caller picked the right key. +""" + +from __future__ import annotations + +from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType + + +class MintPolicyViolation(Exception): # noqa: N818 — spec-defined name, used in BadRequest message + """Raised on a (subject_type, prefix, scopes) mismatch. Callers translate + to 400 ``mint_policy_violation``.""" + + +def validate_mint_policy( + *, + subject_type: SubjectType, + prefix: str, + scopes: frozenset[Scope], +) -> None: + """Raise ``MintPolicyViolation`` when the triple does not match the + canonical ``MINTABLE_PROFILES`` entry for ``subject_type``. + """ + profile = MINTABLE_PROFILES.get(subject_type) + if profile is None: + raise MintPolicyViolation(f"mint_policy_violation: unknown subject_type={subject_type!r}") + + drift = [] + if profile.prefix != prefix: + drift.append(f"prefix got={prefix!r} expected={profile.prefix!r}") + if frozenset(scopes) != profile.scopes: + got = sorted(s.value for s in scopes) + want = sorted(s.value for s in profile.scopes) + drift.append(f"scopes got={got} expected={want}") + + if drift: + raise MintPolicyViolation(f"mint_policy_violation: subject_type={subject_type.value} — " + "; ".join(drift)) diff --git a/api/services/openapi/visibility.py b/api/services/openapi/visibility.py new file mode 100644 index 0000000000..ed665a768f --- /dev/null +++ b/api/services/openapi/visibility.py @@ -0,0 +1,32 @@ +"""Single-source visibility filter for the /openapi/v1/* surface. + +Keep every openapi-surface app query routed through ``_apply_openapi_gate``; +retiring or replacing the gate then becomes a one-line change here. + +The Service API (/v1/* app-key surface) does NOT use this helper — that +surface has its own per-request guard (``service_api_disabled``) wired +into the legacy ``validate_app_token`` decorator. +""" + +from __future__ import annotations + +from typing import Any + +from models.model import App + + +def apply_openapi_gate(query: Any) -> Any: + """Filter a SQLAlchemy Select/Query to apps visible on /openapi/v1/*. + + Works with both legacy ``Query.filter`` and 2.0-style ``Select.filter`` + (alias of ``.where``). + """ + return query.filter(App.enable_api.is_(True)) + + +def is_openapi_visible(app: App) -> bool: + """Per-row counterpart for code paths that fetch an App by primary key + (``session.get`` / ``session.scalar``) and need the same visibility check + the query gate would have applied. + """ + return bool(app.enable_api) diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index eaea79af2f..834d78011a 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -15,7 +15,7 @@ from models import Account, AccountStatus from models.model import App, EndUser, Site from services.account_service import AccountService from services.app_service import AppService -from services.enterprise.enterprise_service import EnterpriseService +from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService, WebAppAccessMode from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError from tasks.mail_email_code_login import send_email_code_login_mail_task @@ -137,12 +137,8 @@ class WebAppAuthService: """ Check if the app requires permission check based on its access mode. """ - modes_requiring_permission_check = [ - "private", - "private_all", - ] if access_mode: - return access_mode in modes_requiring_permission_check + return access_mode in PERMISSION_CHECK_MODES if not app_code and not app_id: raise ValueError("Either app_code or app_id must be provided.") @@ -153,7 +149,7 @@ class WebAppAuthService: raise ValueError("App ID could not be determined from the provided app_code.") webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id) - if webapp_settings and webapp_settings.access_mode in modes_requiring_permission_check: + if webapp_settings and webapp_settings.access_mode in PERMISSION_CHECK_MODES: return True return False @@ -166,11 +162,11 @@ class WebAppAuthService: raise ValueError("Either app_code or access_mode must be provided.") if access_mode: - if access_mode == "public": + if access_mode == WebAppAccessMode.PUBLIC: return WebAppAuthType.PUBLIC - elif access_mode in ["private", "private_all"]: + elif access_mode in PERMISSION_CHECK_MODES: return WebAppAuthType.INTERNAL - elif access_mode == "sso_verified": + elif access_mode == WebAppAccessMode.SSO_VERIFIED: return WebAppAuthType.EXTERNAL if app_code: diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index 5ceeb302c8..ad98e95b03 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -162,12 +162,18 @@ class _AppRunner: user = self._resolve_user() with self._setup_flask_context(user): - response = self._run_app( - app=app, - workflow=workflow, - user=user, - pause_state_config=pause_config, - ) + try: + response = self._run_app( + app=app, + workflow=workflow, + user=user, + pause_state_config=pause_config, + ) + except Exception as exc: + if exec_params.streaming: + _publish_error_event(exc, exec_params.workflow_run_id, exec_params.app_mode) + raise + if not exec_params.streaming: return response @@ -238,6 +244,12 @@ def _resolve_user_for_run(session: Session, workflow_run: WorkflowRun) -> Accoun return session.get(EndUser, workflow_run.created_by) +def _publish_error_event(exc: Exception, workflow_run_id: str, app_mode: AppMode) -> None: + topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id) + payload = json.dumps({"event": "error", "message": str(exc), "status": 500}) + topic.publish(payload.encode()) + + def _publish_streaming_response( response_stream: Generator[str | Mapping[str, Any] | BaseModel, None, None], workflow_run_id: str, diff --git a/api/tests/integration_tests/controllers/openapi/__init__.py b/api/tests/integration_tests/controllers/openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py new file mode 100644 index 0000000000..19a8ab673b --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -0,0 +1,125 @@ +"""Shared fixtures for /openapi/v1/* integration tests.""" + +from __future__ import annotations + +import hashlib +import uuid +from collections.abc import Generator +from datetime import UTC, datetime, timedelta + +import pytest +from flask import Flask + +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from models import Account, App, OAuthAccessToken, Tenant, TenantAccountJoin +from models.account import AccountStatus + + +def _sha256(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +@pytest.fixture(autouse=True) +def disable_enterprise(monkeypatch): + """Default to CE behaviour for /openapi/v1 tests. Tests that exercise the + EE branch override this with their own monkeypatch in-test.""" + from configs import dify_config + + monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False) + + +@pytest.fixture +def workspace_account(flask_app: Flask) -> Generator[tuple[Account, Tenant, TenantAccountJoin], None, None]: + with flask_app.app_context(): + tenant = Tenant(name="t1", status="normal") + account = Account(email="u@example.com", name="u") + db.session.add_all([tenant, account]) + db.session.commit() + account.status = AccountStatus.ACTIVE + join = TenantAccountJoin(tenant_id=tenant.id, account_id=account.id, role="owner") + db.session.add(join) + db.session.commit() + yield account, tenant, join + db.session.delete(join) + db.session.delete(account) + db.session.delete(tenant) + db.session.commit() + + +@pytest.fixture +def app_in_workspace(flask_app: Flask, workspace_account) -> Generator[App, None, None]: + _, tenant, _ = workspace_account + with flask_app.app_context(): + app = App(tenant_id=tenant.id, name="a", mode="chat", status="normal", enable_site=True, enable_api=True) + db.session.add(app) + db.session.commit() + yield app + db.session.delete(app) + db.session.commit() + + +@pytest.fixture +def mint_token(flask_app: Flask): + """Factory fixture; tracks minted rows and deletes them on teardown so + the auth-related test runs don't accumulate `oauth_access_tokens` rows.""" + minted: list[OAuthAccessToken] = [] + + def _mint( + token: str, + *, + account_id: str | None, + prefix: str, + subject_email: str, + subject_issuer: str | None, + ) -> OAuthAccessToken: + with flask_app.app_context(): + row = OAuthAccessToken( + token_hash=_sha256(token), + prefix=prefix, + account_id=account_id, + subject_email=subject_email, + subject_issuer=subject_issuer, + client_id="difyctl", + device_label="test-device", + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + db.session.add(row) + db.session.commit() + minted.append(row) + return row + + yield _mint + + with flask_app.app_context(): + for row in minted: + db.session.delete(db.session.merge(row)) + db.session.commit() + + +@pytest.fixture +def account_token(workspace_account, mint_token) -> str: + account, _, _ = workspace_account + token = "dfoa_" + uuid.uuid4().hex + mint_token( + token, + account_id=account.id, + prefix="dfoa_", + subject_email=account.email, + subject_issuer="dify:account", + ) + return token + + +@pytest.fixture(autouse=True) +def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]: + def _flush(): + with flask_app.app_context(): + for k in redis_client.keys("auth:*"): + redis_client.delete(k) + for k in redis_client.keys("rl:*"): + redis_client.delete(k) + + _flush() + yield + _flush() diff --git a/api/tests/integration_tests/controllers/openapi/test_app_run.py b/api/tests/integration_tests/controllers/openapi/test_app_run.py new file mode 100644 index 0000000000..92e2e993db --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_app_run.py @@ -0,0 +1,238 @@ +"""Integration tests for POST /openapi/v1/apps//run.""" + +from __future__ import annotations + +import uuid +from collections.abc import Generator + +import pytest +from flask import Flask + +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from models import App + + +def test_run_chat_dispatches_to_chat_handler(flask_app, account_token, app_in_workspace, monkeypatch): + captured = {} + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + captured["mode"] = app_model.mode + captured["args"] = args + captured["invoke_from"] = invoke_from + return { + "event": "message", + "task_id": "t", + "id": "m", + "message_id": "m", + "conversation_id": "c", + "mode": "chat", + "answer": "ok", + "created_at": 0, + } + + monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "blocking", "user": "spoof@x.com"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.get_json()["mode"] == "chat" + assert captured["mode"] == "chat" + assert captured["invoke_from"] == InvokeFrom.OPENAPI + assert "user" not in captured["args"], "server must strip body.user; identity comes from bearer" + + +@pytest.fixture +def app_with_mode(flask_app: Flask, workspace_account): + """Factory that creates an App row in the workspace_account tenant with + a specified mode. Tracks rows for teardown. + """ + _, tenant, _ = workspace_account + created: list[App] = [] + + def _make(mode: str) -> App: + with flask_app.app_context(): + app = App( + tenant_id=tenant.id, + name=f"a-{mode}", + mode=mode, + status="normal", + enable_site=True, + enable_api=True, + ) + db.session.add(app) + db.session.commit() + db.session.refresh(app) + db.session.expunge(app) + created.append(app) + return app + + yield _make + + with flask_app.app_context(): + for app in created: + db.session.delete(db.session.merge(app)) + db.session.commit() + + +def test_run_chat_without_query_returns_422(flask_app, account_token, app_in_workspace, monkeypatch): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"query_required_for_chat" in res.data + + +def test_run_completion_dispatches_to_completion_handler(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("completion") + + captured: dict = {} + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + captured["mode"] = app_model.mode + captured["args"] = args + return { + "event": "message", + "task_id": "t", + "id": "m", + "message_id": "m", + "mode": "completion", + "answer": "ok", + "created_at": 0, + } + + monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.get_json()["mode"] == "completion" + assert captured["mode"] == "completion" + + +def test_run_workflow_with_query_returns_422(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("workflow") + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"query_not_supported_for_workflow" in res.data + + +def test_run_workflow_no_query_dispatches_to_workflow_handler(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("workflow") + + def _fake_generate(*, app_model, user, args, invoke_from, streaming): + return { + "workflow_run_id": "wfr", + "task_id": "t", + "data": {"id": "wf-d", "workflow_id": "wf", "status": "succeeded"}, + } + + monkeypatch.setattr("controllers.openapi.app_run.AppGenerateService.generate", staticmethod(_fake_generate)) + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.get_json() + assert body["mode"] == "workflow" + assert body["workflow_run_id"] == "wfr" + + +def test_run_unsupported_mode_returns_422(flask_app, account_token, app_with_mode, monkeypatch): + app = app_with_mode("channel") + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app.id}/run", + json={"inputs": {}, "response_mode": "blocking"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + assert b"mode_not_runnable" in res.data + + +def test_run_without_bearer_returns_401(flask_app, app_in_workspace): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi"}, + ) + assert res.status_code == 401 + + +def test_run_with_insufficient_scope_returns_403(flask_app, account_token, app_in_workspace, monkeypatch): + """Stub the authenticator to return an AuthContext with empty scopes.""" + from libs import oauth_bearer + + real_authenticate = oauth_bearer.BearerAuthenticator.authenticate + + def _stub_authenticate(self, token: str): + ctx = real_authenticate(self, token) + from dataclasses import replace + + return replace(ctx, scopes=frozenset()) + + monkeypatch.setattr(oauth_bearer.BearerAuthenticator, "authenticate", _stub_authenticate) + + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 403 + + +def test_run_with_unknown_app_returns_404(flask_app, account_token): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{uuid.uuid4()}/run", + json={"inputs": {}, "query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 404 + + +def test_run_streaming_returns_event_stream(flask_app, account_token, app_in_workspace, monkeypatch): + def _stream() -> Generator[str, None, None]: + yield 'event: message\ndata: {"x": 1}\n\n' + + monkeypatch.setattr( + "controllers.openapi.app_run.AppGenerateService.generate", + staticmethod(lambda **kw: _stream()), + ) + + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"inputs": {}, "query": "hi", "response_mode": "streaming"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.headers["Content-Type"].startswith("text/event-stream") + assert b"event: message" in res.data + + +def test_run_without_inputs_returns_422(flask_app, account_token, app_in_workspace): + client = flask_app.test_client() + res = client.post( + f"/openapi/v1/apps/{app_in_workspace.id}/run", + json={"query": "hi"}, + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 diff --git a/api/tests/integration_tests/controllers/openapi/test_apps.py b/api/tests/integration_tests/controllers/openapi/test_apps.py new file mode 100644 index 0000000000..20ac46fbbd --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_apps.py @@ -0,0 +1,210 @@ +"""Integration tests for /openapi/v1/apps* read surface.""" + +from __future__ import annotations + +from flask.testing import FlaskClient + +from models import App + + +def test_apps_bare_id_route_404(test_client, app_in_workspace, account_token): + resp = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert resp.status_code == 404 + + +def test_apps_parameters_route_404(test_client, app_in_workspace, account_token): + resp = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/parameters", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert resp.status_code == 404 + + +def test_apps_info_route_404(test_client, app_in_workspace, account_token): + resp = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/info", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert resp.status_code == 404 + + +def test_apps_describe_returns_merged_shape( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"]["id"] == app_in_workspace.id + assert body["info"]["mode"] == "chat" + assert isinstance(body["parameters"], dict) + + +def test_apps_describe_full_includes_input_schema( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"] is not None + assert body["parameters"] is not None + assert body["input_schema"] is not None + assert body["input_schema"]["$schema"] == "https://json-schema.org/draft/2020-12/schema" + + +def test_apps_describe_fields_info_only( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"] is not None + assert body["parameters"] is None + assert body["input_schema"] is None + + +def test_apps_describe_fields_parameters_only( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=parameters", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"] is None + assert body["parameters"] is not None + assert body["input_schema"] is None + + +def test_apps_describe_fields_input_schema_only( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=input_schema", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"] is None + assert body["parameters"] is None + assert body["input_schema"] is not None + + +def test_apps_describe_fields_combined( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info,input_schema", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["info"] is not None + assert body["parameters"] is None + assert body["input_schema"] is not None + + +def test_apps_describe_fields_unknown_returns_422( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=garbage", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + + +def test_apps_describe_fields_extra_param_returns_422( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +): + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/describe?fields=info&page=1", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 422 + + +def test_apps_list_returns_pagination_envelope( + test_client: FlaskClient, + workspace_account, + app_in_workspace: App, + account_token: str, +): + _, tenant, _ = workspace_account + res = test_client.get( + f"/openapi/v1/apps?workspace_id={tenant.id}&page=1&limit=20", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + body = res.json + assert body["page"] == 1 + assert body["limit"] == 20 + assert body["total"] >= 1 + assert any(d["id"] == app_in_workspace.id for d in body["data"]) + + +def test_apps_list_requires_workspace_id(test_client: FlaskClient, account_token: str): + res = test_client.get("/openapi/v1/apps", headers={"Authorization": f"Bearer {account_token}"}) + assert res.status_code == 400 + + +def test_apps_list_tag_no_match_returns_empty_data_not_400( + test_client: FlaskClient, + workspace_account, + app_in_workspace: App, + account_token: str, +): + _, tenant, _ = workspace_account + res = test_client.get( + f"/openapi/v1/apps?workspace_id={tenant.id}&tag=nonexistent", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.json["data"] == [] + + +def test_account_sessions_returns_envelope( + test_client: FlaskClient, + account_token: str, +): + res = test_client.get("/openapi/v1/account/sessions", headers={"Authorization": f"Bearer {account_token}"}) + assert res.status_code == 200 + body = res.json + # canonical envelope shape + assert isinstance(body["data"], list) + assert "page" in body + assert "limit" in body + assert "total" in body + assert "has_more" in body + # the bearer's own minted session must appear + assert any(s["prefix"] == "dfoa_" for s in body["data"]) + # legacy "sessions" key must NOT appear + assert "sessions" not in body diff --git a/api/tests/integration_tests/controllers/openapi/test_auth.py b/api/tests/integration_tests/controllers/openapi/test_auth.py new file mode 100644 index 0000000000..5f0727fbbe --- /dev/null +++ b/api/tests/integration_tests/controllers/openapi/test_auth.py @@ -0,0 +1,127 @@ +"""Integration tests for the /openapi/v1 bearer auth surface. + +Layer 0 (workspace membership), per-token rate limit, and read-scope (`apps:read`) +acceptance/rejection on app-scoped routes. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from flask import Flask +from flask.testing import FlaskClient + +from extensions.ext_database import db +from models import App, Tenant + + +def test_info_accepts_account_bearer_with_apps_read_scope( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +) -> None: + res = test_client.get( + f"/openapi/v1/apps/{app_in_workspace.id}/info", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.json["id"] == app_in_workspace.id + + +@pytest.fixture +def other_workspace_app(flask_app: Flask) -> Generator[App, None, None]: + """A fresh app under a *different* tenant — caller has no membership row.""" + with flask_app.app_context(): + other_tenant = Tenant(name="other", status="normal") + db.session.add(other_tenant) + db.session.commit() + app = App( + tenant_id=other_tenant.id, + name="b", + mode="chat", + status="normal", + enable_site=True, + enable_api=True, + ) + db.session.add(app) + db.session.commit() + yield app + db.session.delete(app) + db.session.delete(other_tenant) + db.session.commit() + + +def test_layer0_denies_account_bearer_without_membership( + test_client: FlaskClient, + account_token: str, + other_workspace_app: App, +) -> None: + """Account A bearer hitting an app under tenant B — Layer 0 denies on CE.""" + res = test_client.get( + f"/openapi/v1/apps/{other_workspace_app.id}/info", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 403 + assert res.json.get("message") == "workspace_membership_revoked" + + +def test_layer0_skipped_when_enterprise_enabled( + test_client: FlaskClient, + account_token: str, + other_workspace_app: App, + monkeypatch, +) -> None: + """On EE, Layer 0 short-circuits — gateway RBAC owns tenant isolation. + + /info uses validate_bearer + require_workspace_member inline (no + AppAuthzCheck), so a cross-tenant bearer reaches the app lookup and + gets 200 — gateway is expected to enforce isolation upstream. + """ + from configs import dify_config + + # Override the conftest autouse default for this test only. + monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True) + + res = test_client.get( + f"/openapi/v1/apps/{other_workspace_app.id}/info", + headers={"Authorization": f"Bearer {account_token}"}, + ) + assert res.status_code == 200 + assert res.json.get("message") != "workspace_membership_revoked" + + +def test_rate_limit_returns_429_after_60_requests( + test_client: FlaskClient, + account_token: str, +) -> None: + """61st sequential GET to /account on the same bearer → 429 with Retry-After.""" + headers = {"Authorization": f"Bearer {account_token}"} + for i in range(60): + r = test_client.get("/openapi/v1/account", headers=headers) + assert r.status_code == 200, f"unexpected fail at i={i}" + + r = test_client.get("/openapi/v1/account", headers=headers) + assert r.status_code == 429 + assert r.headers.get("Retry-After"), "Retry-After header missing" + assert int(r.headers["Retry-After"]) >= 1 + body = r.json or {} + assert body.get("error") == "rate_limited" + assert isinstance(body.get("retry_after_ms"), int) + assert body["retry_after_ms"] >= 1000 + + +def test_rate_limit_bucket_shared_across_surfaces( + test_client: FlaskClient, + app_in_workspace: App, + account_token: str, +) -> None: + """30 calls to /account + 30 calls to /apps//info on same token → 61st 429s.""" + headers = {"Authorization": f"Bearer {account_token}"} + for _ in range(30): + assert test_client.get("/openapi/v1/account", headers=headers).status_code == 200 + for _ in range(30): + assert test_client.get(f"/openapi/v1/apps/{app_in_workspace.id}/info", headers=headers).status_code == 200 + + r = test_client.get("/openapi/v1/account", headers=headers) + assert r.status_code == 429 diff --git a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py index b65db70b50..aa0a759ffa 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_markdown_docs.py @@ -56,6 +56,7 @@ def test_generate_markdown_docs_keeps_split_docs_and_merges_fastopenapi_into_con "console-swagger.md", "web-swagger.md", "service-swagger.md", + "openapi-swagger.md", ] assert not stale_combined_doc.exists() assert not list(swagger_dir.glob("*.json")) diff --git a/api/tests/unit_tests/commands/test_generate_swagger_specs.py b/api/tests/unit_tests/commands/test_generate_swagger_specs.py index 79a577087d..7b2ed78f56 100644 --- a/api/tests/unit_tests/commands/test_generate_swagger_specs.py +++ b/api/tests/unit_tests/commands/test_generate_swagger_specs.py @@ -39,6 +39,7 @@ def test_generate_specs_writes_console_web_and_service_swagger_files(tmp_path): "console-swagger.json", "web-swagger.json", "service-swagger.json", + "openapi-swagger.json", ] for path in written_paths: diff --git a/api/tests/unit_tests/controllers/openapi/__init__.py b/api/tests/unit_tests/controllers/openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/controllers/openapi/auth/__init__.py b/api/tests/unit_tests/controllers/openapi/auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py new file mode 100644 index 0000000000..aa6478dd97 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -0,0 +1,66 @@ +from unittest.mock import patch + +from controllers.openapi.auth.composition import OAUTH_BEARER_PIPELINE, _resolve_app_authz_strategy +from controllers.openapi.auth.pipeline import Pipeline +from controllers.openapi.auth.steps import ( + AppAuthzCheck, + AppResolver, + BearerCheck, + CallerMount, + ScopeCheck, + SurfaceCheck, + WorkspaceMembershipCheck, +) +from controllers.openapi.auth.strategies import ( + AccountMounter, + AclStrategy, + EndUserMounter, + MembershipStrategy, +) +from libs.oauth_bearer import SubjectType + + +def test_pipeline_is_composed(): + assert isinstance(OAUTH_BEARER_PIPELINE, Pipeline) + + +def test_pipeline_step_order(): + """BearerCheck → SurfaceCheck → ScopeCheck → AppResolver → + WorkspaceMembershipCheck → AppAuthzCheck → CallerMount. + SurfaceCheck enforces the dfoa_/dfoe_ surface split + emits + `openapi.wrong_surface_denied`. Rate-limit is enforced inside + `BearerAuthenticator.authenticate`, not as a separate pipeline step.""" + steps = OAUTH_BEARER_PIPELINE._steps + assert isinstance(steps[0], BearerCheck) + assert isinstance(steps[1], SurfaceCheck) + assert isinstance(steps[2], ScopeCheck) + assert isinstance(steps[3], AppResolver) + assert isinstance(steps[4], WorkspaceMembershipCheck) + assert isinstance(steps[5], AppAuthzCheck) + assert isinstance(steps[6], CallerMount) + + +def test_pipeline_surface_check_accepts_account_only(): + """Current pipeline serves /apps//run — account surface only.""" + surface = OAUTH_BEARER_PIPELINE._steps[1] + assert isinstance(surface, SurfaceCheck) + assert surface._accepted == frozenset({SubjectType.ACCOUNT}) + + +def test_caller_mount_has_both_mounters(): + cm = OAUTH_BEARER_PIPELINE._steps[6] + kinds = {type(m) for m in cm._mounters} + assert AccountMounter in kinds + assert EndUserMounter in kinds + + +@patch("controllers.openapi.auth.composition.FeatureService") +def test_strategy_resolver_picks_acl_when_enabled(fs): + fs.get_system_features.return_value.webapp_auth.enabled = True + assert isinstance(_resolve_app_authz_strategy(), AclStrategy) + + +@patch("controllers.openapi.auth.composition.FeatureService") +def test_strategy_resolver_picks_membership_when_disabled(fs): + fs.get_system_features.return_value.webapp_auth.enabled = False + assert isinstance(_resolve_app_authz_strategy(), MembershipStrategy) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_context.py b/api/tests/unit_tests/controllers/openapi/auth/test_context.py new file mode 100644 index 0000000000..cc9c011342 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_context.py @@ -0,0 +1,21 @@ +from controllers.openapi.auth.context import Context + + +def test_context_starts_unpopulated(): + ctx = Context(required_scope="apps:run") + assert ctx.bearer_token is None + assert ctx.path_params == {} + assert ctx.subject_type is None + assert ctx.subject_email is None + assert ctx.account_id is None + assert ctx.scopes == frozenset() + assert ctx.app is None + assert ctx.tenant is None + assert ctx.caller is None + assert ctx.caller_kind is None + + +def test_context_fields_are_mutable(): + ctx = Context(required_scope="apps:run") + ctx.scopes = frozenset({"full"}) + assert "full" in ctx.scopes diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py new file mode 100644 index 0000000000..15538275f5 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py @@ -0,0 +1,59 @@ +import pytest +from flask import Flask + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.pipeline import Pipeline + + +def test_run_invokes_each_step_in_order(): + calls = [] + + class S: + def __init__(self, tag): + self.tag = tag + + def __call__(self, ctx): + calls.append(self.tag) + + Pipeline(S("a"), S("b"), S("c")).run(Context(required_scope="x")) + assert calls == ["a", "b", "c"] + + +def test_run_short_circuits_on_raise(): + calls = [] + + class Boom: + def __call__(self, ctx): + raise RuntimeError("boom") + + class Tail: + def __call__(self, ctx): + calls.append("ran") + + with pytest.raises(RuntimeError): + Pipeline(Boom(), Tail()).run(Context(required_scope="x")) + assert calls == [] + + +def test_guard_decorator_runs_pipeline_and_unpacks_handler_kwargs(): + seen = {} + + class FakeStep: + def __call__(self, ctx): + ctx.app = "APP" + ctx.caller = "CALLER" + ctx.caller_kind = "account" + + pipeline = Pipeline(FakeStep()) + + @pipeline.guard(scope="apps:run") + def handler(app_model, caller, caller_kind): + seen["app_model"] = app_model + seen["caller"] = caller + seen["caller_kind"] = caller_kind + return "ok" + + app = Flask(__name__) + with app.test_request_context("/x", method="POST"): + assert handler() == "ok" + assert seen == {"app_model": "APP", "caller": "CALLER", "caller_kind": "account"} diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_app_resolver.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_app_resolver.py new file mode 100644 index 0000000000..f051f1a71c --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_app_resolver.py @@ -0,0 +1,64 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import BadRequest, Forbidden, NotFound + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import AppResolver +from models import TenantStatus + + +def _ctx(path_params: dict[str, str] | None) -> Context: + return Context(required_scope="apps:run", path_params=path_params or {}) + + +def _app(*, status="normal", enable_api=True): + return SimpleNamespace(id="app1", tenant_id="t1", status=status, enable_api=enable_api) + + +def _tenant(*, status=TenantStatus.NORMAL): + return SimpleNamespace(id="t1", status=status) + + +def test_resolver_rejects_missing_path_param(): + with pytest.raises(BadRequest): + AppResolver()(_ctx({})) + + +def test_resolver_rejects_empty_path_params(): + # `Pipeline.guard` always seeds an empty dict when Flask reports no + # view args, so a missing `app_id` key surfaces here as BadRequest. + with pytest.raises(BadRequest): + AppResolver()(_ctx(None)) + + +@patch("controllers.openapi.auth.steps.db") +def test_resolver_404_when_app_missing(db): + db.session.get.side_effect = [None] + with pytest.raises(NotFound): + AppResolver()(_ctx({"app_id": "x"})) + + +@patch("controllers.openapi.auth.steps.db") +def test_resolver_403_when_disabled(db): + db.session.get.side_effect = [_app(enable_api=False)] + with pytest.raises(Forbidden) as exc: + AppResolver()(_ctx({"app_id": "x"})) + assert "service_api_disabled" in str(exc.value.description) + + +@patch("controllers.openapi.auth.steps.db") +def test_resolver_403_when_tenant_archived(db): + db.session.get.side_effect = [_app(), _tenant(status=TenantStatus.ARCHIVE)] + with pytest.raises(Forbidden): + AppResolver()(_ctx({"app_id": "x"})) + + +@patch("controllers.openapi.auth.steps.db") +def test_resolver_populates_app_and_tenant(db): + db.session.get.side_effect = [_app(), _tenant()] + ctx = _ctx({"app_id": "x"}) + AppResolver()(ctx) + assert ctx.app.id == "app1" + assert ctx.tenant.id == "t1" diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_authz.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_authz.py new file mode 100644 index 0000000000..6a5933da3b --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_authz.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import Forbidden + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import AppAuthzCheck +from controllers.openapi.auth.strategies import AclStrategy, MembershipStrategy +from libs.oauth_bearer import SubjectType + + +def _ctx(*, subject_type, account_id="acc1"): + c = Context(required_scope="apps:run") + c.subject_type = subject_type + c.subject_email = "alice@example.com" + c.account_id = account_id + c.app = SimpleNamespace(id="app1") + c.tenant = SimpleNamespace(id="t1") + return c + + +@patch("controllers.openapi.auth.strategies.EnterpriseService") +def test_acl_strategy_private_calls_inner_api(ent): + ent.WebAppAuth.get_app_access_mode_by_id.return_value = SimpleNamespace(access_mode="private") + ent.WebAppAuth.is_user_allowed_to_access_webapp.return_value = True + assert AclStrategy().authorize(_ctx(subject_type=SubjectType.ACCOUNT)) is True + ent.WebAppAuth.is_user_allowed_to_access_webapp.assert_called_once_with( + user_id="acc1", + app_id="app1", + ) + + +@pytest.mark.parametrize( + ("access_mode", "subject_type", "expected"), + [ + ("public", SubjectType.ACCOUNT, True), + ("public", SubjectType.EXTERNAL_SSO, True), + ("sso_verified", SubjectType.ACCOUNT, True), + ("sso_verified", SubjectType.EXTERNAL_SSO, True), + ("private_all", SubjectType.ACCOUNT, True), + ("private_all", SubjectType.EXTERNAL_SSO, False), + ("private", SubjectType.EXTERNAL_SSO, False), + ], +) +@patch("controllers.openapi.auth.strategies.EnterpriseService") +def test_acl_strategy_subject_mode_matrix(ent, access_mode, subject_type, expected): + """Step 1 matrix: subject vs access-mode compatibility. No inner API call expected.""" + ent.WebAppAuth.get_app_access_mode_by_id.return_value = SimpleNamespace(access_mode=access_mode) + account_id = "acc1" if subject_type == SubjectType.ACCOUNT else None + assert AclStrategy().authorize(_ctx(subject_type=subject_type, account_id=account_id)) is expected + ent.WebAppAuth.is_user_allowed_to_access_webapp.assert_not_called() + + +@patch("controllers.openapi.auth.strategies.TenantService.account_belongs_to_tenant") +@patch("controllers.openapi.auth.strategies.db") +def test_membership_strategy_uses_join_lookup(db_mock, member): + member.return_value = True + assert MembershipStrategy().authorize(_ctx(subject_type=SubjectType.ACCOUNT)) is True + member.assert_called_once_with(db_mock.session, "acc1", "t1") + + +def test_membership_strategy_rejects_external_sso(): + assert MembershipStrategy().authorize(_ctx(subject_type=SubjectType.EXTERNAL_SSO, account_id=None)) is False + + +def test_app_authz_check_raises_when_strategy_denies(): + deny = SimpleNamespace(authorize=lambda c: False) + with pytest.raises(Forbidden) as exc: + AppAuthzCheck(lambda: deny)(_ctx(subject_type=SubjectType.ACCOUNT)) + assert "subject_no_app_access" in str(exc.value.description) + + +def test_app_authz_check_passes_when_strategy_allows(): + allow = SimpleNamespace(authorize=lambda c: True) + AppAuthzCheck(lambda: allow)(_ctx(subject_type=SubjectType.ACCOUNT)) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_bearer.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_bearer.py new file mode 100644 index 0000000000..329f158f30 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_bearer.py @@ -0,0 +1,83 @@ +import uuid +from datetime import UTC, datetime +from unittest.mock import patch + +import pytest +from flask import Flask +from werkzeug.exceptions import Unauthorized + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import BearerCheck +from libs.oauth_bearer import ( + AuthContext, + InvalidBearerError, + Scope, + SubjectType, + reset_auth_ctx, + try_get_auth_ctx, +) + + +def _ctx(bearer_token: str | None) -> Context: + return Context(required_scope="apps:run", bearer_token=bearer_token) + + +def test_bearer_check_rejects_missing_header(): + app = Flask(__name__) + with app.test_request_context(), pytest.raises(Unauthorized): + BearerCheck()(_ctx(None)) + + +@patch("controllers.openapi.auth.steps.get_authenticator") +def test_bearer_check_rejects_unknown_prefix(get_auth): + get_auth.return_value.authenticate.side_effect = InvalidBearerError("invalid_bearer") + app = Flask(__name__) + with app.test_request_context(), pytest.raises(Unauthorized): + BearerCheck()(_ctx("xxx_abc")) + + +@patch("controllers.openapi.auth.steps.get_authenticator") +def test_bearer_check_populates_context_and_publishes_auth_ctx(get_auth): + tok_id = uuid.uuid4() + authn = AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email="a@x.com", + subject_issuer=None, + account_id=None, + client_id="difyctl", + scopes=frozenset({Scope.FULL}), + token_id=tok_id, + source="oauth-account", + expires_at=datetime.now(UTC), + token_hash="hash-1", + verified_tenants={}, + ) + get_auth.return_value.authenticate.return_value = authn + + app = Flask(__name__) + ctx = _ctx("dfoa_abc") + with app.test_request_context(): + BearerCheck()(ctx) + try: + assert ctx.subject_type == SubjectType.ACCOUNT + assert ctx.subject_email == "a@x.com" + assert ctx.scopes == frozenset({Scope.FULL}) + assert ctx.source == "oauth-account" + assert ctx.token_id == tok_id + assert ctx.token_hash == "hash-1" + # BearerCheck must also publish the same identity on the + # openapi auth ContextVar so the surface gate + downstream + # handlers don't see two different identity sources between + # the decorator + pipeline paths. The reset token is parked + # on `ctx.auth_ctx_reset_token` for `Pipeline.guard` to + # consume in its `finally`. + published = try_get_auth_ctx() + assert published is authn + assert published.client_id == "difyctl" + assert ctx.auth_ctx_reset_token is not None + finally: + # In production `Pipeline.guard` resets the ContextVar; in + # this isolated step-level test we reset it ourselves so the + # value doesn't leak into the next test on the same worker. + assert ctx.auth_ctx_reset_token is not None + reset_auth_ctx(ctx.auth_ctx_reset_token) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_layer0.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_layer0.py new file mode 100644 index 0000000000..82ea07d736 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_layer0.py @@ -0,0 +1,157 @@ +"""Unit tests for WorkspaceMembershipCheck (Layer 0).""" + +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import Forbidden + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import WorkspaceMembershipCheck +from libs.oauth_bearer import SubjectType + + +def _ctx(*, subject_type, account_id, tenant_id, cached_verified_tenants=None, token_hash=None) -> Context: + c = Context(required_scope="apps:read") + c.subject_type = subject_type + c.account_id = account_id + c.tenant = SimpleNamespace(id=tenant_id) if tenant_id else None + c.cached_verified_tenants = cached_verified_tenants + c.token_hash = token_hash + return c + + +@pytest.fixture +def step(): + return WorkspaceMembershipCheck() + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_skips_when_enterprise_enabled(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = True + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id=str(uuid.uuid4()), + tenant_id=str(uuid.uuid4()), + cached_verified_tenants={}, + token_hash="hash-1", + ) + step(ctx) # no raise + mock_db.session.execute.assert_not_called() + mock_record.assert_not_called() + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_skips_for_external_sso(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + ctx = _ctx( + subject_type=SubjectType.EXTERNAL_SSO, + account_id=None, + tenant_id=str(uuid.uuid4()), + cached_verified_tenants={}, + token_hash="hash-1", + ) + step(ctx) # no raise + mock_db.session.execute.assert_not_called() + mock_record.assert_not_called() + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_uses_cached_ok(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id="a1", + tenant_id="t1", + cached_verified_tenants={"t1": True}, + token_hash="hash-1", + ) + step(ctx) + mock_db.session.execute.assert_not_called() + mock_record.assert_not_called() + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_uses_cached_denied(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id="a1", + tenant_id="t1", + cached_verified_tenants={"t1": False}, + token_hash="hash-1", + ) + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + step(ctx) + mock_db.session.execute.assert_not_called() + mock_record.assert_not_called() + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_denies_when_no_membership(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.return_value.scalar_one_or_none.return_value = None + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id="a1", + tenant_id="t1", + cached_verified_tenants={}, + token_hash="hash-1", + ) + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + step(ctx) + mock_record.assert_called_once_with("hash-1", "t1", False) + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_denies_when_account_inactive(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.side_effect = [ + MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")), + MagicMock(scalar_one_or_none=MagicMock(return_value="banned")), + ] + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id="a1", + tenant_id="t1", + cached_verified_tenants={}, + token_hash="hash-1", + ) + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + step(ctx) + mock_record.assert_called_once_with("hash-1", "t1", False) + + +@patch("controllers.openapi.auth.steps.dify_config") +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +def test_allows_active_member(mock_db, mock_record, mock_cfg, step): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.side_effect = [ + MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")), + MagicMock(scalar_one_or_none=MagicMock(return_value="active")), + ] + ctx = _ctx( + subject_type=SubjectType.ACCOUNT, + account_id="a1", + tenant_id="t1", + cached_verified_tenants={}, + token_hash="hash-1", + ) + step(ctx) # no raise + mock_record.assert_called_once_with("hash-1", "t1", True) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_mount.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_mount.py new file mode 100644 index 0000000000..8c5ad38a16 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_mount.py @@ -0,0 +1,77 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import Unauthorized + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import CallerMount +from controllers.openapi.auth.strategies import AccountMounter, EndUserMounter +from core.app.entities.app_invoke_entities import InvokeFrom +from libs.oauth_bearer import SubjectType + + +def _ctx(*, subject_type, account_id=None, subject_email=None): + c = Context(required_scope="apps:run") + c.subject_type = subject_type + c.account_id = account_id + c.subject_email = subject_email + c.app = SimpleNamespace(id="app1") + c.tenant = SimpleNamespace(id="t1") + return c + + +@patch("controllers.openapi.auth.strategies._login_as") +@patch("controllers.openapi.auth.strategies.db") +def test_account_mounter(db, login): + account = SimpleNamespace() + db.session.get.return_value = account + ctx = _ctx(subject_type=SubjectType.ACCOUNT, account_id="acc1") + AccountMounter().mount(ctx) + assert ctx.caller is account + assert ctx.caller.current_tenant is ctx.tenant + assert ctx.caller_kind == "account" + login.assert_called_once_with(account) + + +@patch("controllers.openapi.auth.strategies._login_as") +@patch("controllers.openapi.auth.strategies.EndUserService") +def test_end_user_mounter(svc, login): + eu = SimpleNamespace() + svc.get_or_create_end_user_by_type.return_value = eu + ctx = _ctx(subject_type=SubjectType.EXTERNAL_SSO, subject_email="a@x.com") + EndUserMounter().mount(ctx) + svc.get_or_create_end_user_by_type.assert_called_once_with( + InvokeFrom.OPENAPI, + tenant_id="t1", + app_id="app1", + user_id="a@x.com", + ) + assert ctx.caller is eu + assert ctx.caller_kind == "end_user" + + +def test_caller_mount_dispatches_by_subject_type(): + seen = {} + + class Fake: + def __init__(self, st, tag): + self._st, self._tag = st, tag + + def applies_to(self, st): + return st == self._st + + def mount(self, ctx): + seen["who"] = self._tag + + cm = CallerMount( + Fake(SubjectType.ACCOUNT, "acct"), + Fake(SubjectType.EXTERNAL_SSO, "sso"), + ) + cm(_ctx(subject_type=SubjectType.EXTERNAL_SSO)) + assert seen == {"who": "sso"} + + +def test_caller_mount_raises_when_none_applies(): + with pytest.raises(Unauthorized): + CallerMount()(_ctx(subject_type=SubjectType.ACCOUNT)) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_step_scope.py b/api/tests/unit_tests/controllers/openapi/auth/test_step_scope.py new file mode 100644 index 0000000000..b4adbacd1e --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_step_scope.py @@ -0,0 +1,25 @@ +import pytest +from werkzeug.exceptions import Forbidden + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import ScopeCheck + + +def _ctx(scopes, required): + c = Context(required_scope=required) + c.scopes = frozenset(scopes) + return c + + +def test_scope_check_passes_on_full(): + ScopeCheck()(_ctx({"full"}, "apps:run")) + + +def test_scope_check_passes_on_explicit_match(): + ScopeCheck()(_ctx({"apps:run"}, "apps:run")) + + +def test_scope_check_rejects_when_missing(): + with pytest.raises(Forbidden) as exc: + ScopeCheck()(_ctx({"apps:read"}, "apps:run")) + assert "insufficient_scope" in str(exc.value.description) diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_surface_gate.py b/api/tests/unit_tests/controllers/openapi/auth/test_surface_gate.py new file mode 100644 index 0000000000..f3b49b18da --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/auth/test_surface_gate.py @@ -0,0 +1,239 @@ +"""Surface gate tests. + +The gate has two attachment forms — decorator (`accept_subjects`) and +pipeline step (`SurfaceCheck`) — and both must: +- 403 on mismatched subject type with a canonical-path hint +- emit `openapi.wrong_surface_denied` once with the right payload +- pass-through on match +- raise RuntimeError (not 403) if the auth ContextVar is unset — that's + a wiring bug, not a user-driven failure + +Identity is published via `libs.oauth_bearer.set_auth_ctx` / read with +`try_get_auth_ctx`. Tests wrap the publish in a `_publish_auth_ctx` +context manager so the ContextVar resets even when an assertion fails; +that keeps state from leaking into the next test on the same worker. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime +from unittest.mock import patch + +import pytest +from flask import Flask +from werkzeug.exceptions import Forbidden + +from controllers.openapi.auth.context import Context +from controllers.openapi.auth.steps import SurfaceCheck +from controllers.openapi.auth.surface_gate import _coerce_subject_type, accept_subjects, check_surface +from libs.oauth_bearer import AuthContext, Scope, SubjectType, reset_auth_ctx, set_auth_ctx + + +@contextmanager +def _publish_auth_ctx(ctx: AuthContext) -> Iterator[None]: + token = set_auth_ctx(ctx) + try: + yield + finally: + reset_auth_ctx(token) + + +def _account_ctx() -> AuthContext: + return AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email="user@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + scopes=frozenset({Scope.FULL}), + token_id=uuid.uuid4(), + source="oauth_account", + expires_at=datetime.now(UTC), + token_hash="h1", + verified_tenants={}, + ) + + +def _sso_ctx() -> AuthContext: + return AuthContext( + subject_type=SubjectType.EXTERNAL_SSO, + subject_email="sso@partner.com", + subject_issuer="https://idp.partner.com", + account_id=None, + client_id="difyctl", + scopes=frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), + token_id=uuid.uuid4(), + source="oauth_external_sso", + expires_at=datetime.now(UTC), + token_hash="h2", + verified_tenants={}, + ) + + +# --------------------------------------------------------------------------- +# check_surface — shared core +# --------------------------------------------------------------------------- + + +def test_check_surface_passes_when_subject_in_accepted(): + app = Flask(__name__) + with app.test_request_context("/openapi/v1/apps"), _publish_auth_ctx(_account_ctx()): + check_surface(frozenset({SubjectType.ACCOUNT})) # no raise + + +def test_check_surface_rejects_on_wrong_subject_and_emits_audit(): + app = Flask(__name__) + with app.test_request_context("/openapi/v1/permitted-external-apps"), _publish_auth_ctx(_account_ctx()): + with patch("controllers.openapi.auth.surface_gate.emit_wrong_surface") as emit: + with pytest.raises(Forbidden) as exc: + check_surface(frozenset({SubjectType.EXTERNAL_SSO})) + assert "wrong_surface" in exc.value.description + # canonical-path hint should point at the caller's surface, + # not the surface they were rejected from + assert "/openapi/v1/apps" in exc.value.description + emit.assert_called_once() + kwargs = emit.call_args.kwargs + assert kwargs["subject_type"] == SubjectType.ACCOUNT.value + assert kwargs["attempted_path"] == "/openapi/v1/permitted-external-apps" + assert kwargs["client_id"] == "difyctl" + assert kwargs["token_id"] is not None + + +def test_check_surface_rejects_sso_on_account_surface(): + app = Flask(__name__) + with app.test_request_context("/openapi/v1/apps"), _publish_auth_ctx(_sso_ctx()): + with patch("controllers.openapi.auth.surface_gate.emit_wrong_surface") as emit: + with pytest.raises(Forbidden): + check_surface(frozenset({SubjectType.ACCOUNT})) + kwargs = emit.call_args.kwargs + assert kwargs["subject_type"] == SubjectType.EXTERNAL_SSO.value + + +def test_check_surface_runtime_error_when_auth_ctx_missing(): + """Missing auth ContextVar means the bearer layer didn't run — wiring + bug, not a user-driven failure. Surface as RuntimeError (loud) so a + future refactor doesn't accidentally let a route skip authentication + and return a 403 that looks identical to a legitimate wrong-surface + deny. + """ + app = Flask(__name__) + with app.test_request_context("/openapi/v1/apps"): + with pytest.raises(RuntimeError): + check_surface(frozenset({SubjectType.ACCOUNT})) + + +# --------------------------------------------------------------------------- +# @accept_subjects — decorator form +# --------------------------------------------------------------------------- + + +def _make_app() -> Flask: + app = Flask(__name__) + + @app.route("/account-only") + @accept_subjects(SubjectType.ACCOUNT) + def _account_only(): + return "ok" + + @app.route("/external-only") + @accept_subjects(SubjectType.EXTERNAL_SSO) + def _external_only(): + return "ok" + + return app + + +def test_accept_subjects_decorator_passes_on_match(): + app = _make_app() + with app.test_request_context("/account-only"), _publish_auth_ctx(_account_ctx()): + # Re-route through the decorated function by reaching for view_function + view = app.view_functions["_account_only"] + assert view() == "ok" + + +def test_accept_subjects_decorator_403_on_miss(): + app = _make_app() + with app.test_request_context("/external-only"), _publish_auth_ctx(_account_ctx()): + view = app.view_functions["_external_only"] + with patch("controllers.openapi.auth.surface_gate.emit_wrong_surface"): + with pytest.raises(Forbidden): + view() + + +# --------------------------------------------------------------------------- +# SurfaceCheck — pipeline step form +# --------------------------------------------------------------------------- + + +def _pipeline_ctx() -> Context: + # SurfaceCheck reads ``request.path`` from Flask's global request — set up + # via ``app.test_request_context`` in the calling tests — not from Context. + return Context(required_scope=Scope.APPS_RUN) + + +def test_surface_check_passes_on_match(): + step = SurfaceCheck(accepted=frozenset({SubjectType.ACCOUNT})) + app = Flask(__name__) + with app.test_request_context("/openapi/v1/apps/x/run"), _publish_auth_ctx(_account_ctx()): + step(_pipeline_ctx()) # no raise + + +def test_surface_check_rejects_on_miss_and_emits_audit(): + step = SurfaceCheck(accepted=frozenset({SubjectType.EXTERNAL_SSO})) + app = Flask(__name__) + with app.test_request_context("/openapi/v1/apps/x/run"), _publish_auth_ctx(_account_ctx()): + with patch("controllers.openapi.auth.surface_gate.emit_wrong_surface") as emit: + with pytest.raises(Forbidden): + step(_pipeline_ctx()) + emit.assert_called_once() + + +# --------------------------------------------------------------------------- +# _coerce_subject_type — normalises whatever sat on ctx.subject_type +# --------------------------------------------------------------------------- +# +# The gate reads `ctx.subject_type` via `getattr(..., None)`, so the value +# could be a real enum (happy path), a raw string (e.g. rehydrated from a +# dict-shaped context), `None` (attribute missing), or something unexpected +# from a buggy upstream. The coercer must collapse all of that to +# `SubjectType | None` so `check_surface` can do a clean set-membership +# check and emit a clean audit payload. + + +def test_coerce_subject_type_returns_none_for_none(): + assert _coerce_subject_type(None) is None + + +def test_coerce_subject_type_returns_enum_instance_unchanged(): + # Identity matters: we don't want to round-trip through the string + # constructor for an already-valid enum. + assert _coerce_subject_type(SubjectType.ACCOUNT) is SubjectType.ACCOUNT + assert _coerce_subject_type(SubjectType.EXTERNAL_SSO) is SubjectType.EXTERNAL_SSO + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("account", SubjectType.ACCOUNT), + ("external_sso", SubjectType.EXTERNAL_SSO), + ], +) +def test_coerce_subject_type_parses_known_strings(raw: str, expected: SubjectType): + assert _coerce_subject_type(raw) is expected + + +def test_coerce_subject_type_raises_on_unknown_string(): + # Unknown strings reach `SubjectType(raw)` which raises ValueError. + # We surface that loudly rather than silently returning None, because + # a string that *looks* like a subject type but isn't is almost + # certainly an upstream bug worth catching. + with pytest.raises(ValueError): + _coerce_subject_type("not_a_subject") + + +@pytest.mark.parametrize("raw", [123, 1.5, b"account", object(), ["account"], {"account"}]) +def test_coerce_subject_type_returns_none_for_non_string_non_enum(raw: object): + assert _coerce_subject_type(raw) is None diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py new file mode 100644 index 0000000000..38dae79a11 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -0,0 +1,32 @@ +import pytest +from flask import Flask + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.auth.pipeline import Pipeline + + +@pytest.fixture +def bypass_pipeline(monkeypatch): + """Stub Pipeline.run so endpoint decoration does not invoke real auth. + + Module-level @OAUTH_BEARER_PIPELINE.guard(...) captures the real + pipeline at import time; mocking the module attribute does not undo + that. Patching Pipeline.run on the class is the bypass that actually + works. + """ + monkeypatch.setattr(Pipeline, "run", lambda self, ctx: None) + + +@pytest.fixture +def openapi_app(): + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +@pytest.fixture +def app(): + a = Flask(__name__) + a.config["TESTING"] = True + return a diff --git a/api/tests/unit_tests/controllers/openapi/test_account.py b/api/tests/unit_tests/controllers/openapi/test_account.py new file mode 100644 index 0000000000..15624305a3 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_account.py @@ -0,0 +1,140 @@ +"""User-scoped identity + session endpoints under /openapi/v1/account.""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.account import ( + AccountApi, + AccountSessionByIdApi, + AccountSessionsApi, + AccountSessionsSelfApi, +) + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +def test_account_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/account" in rules + + +def test_account_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountApi + + +def test_account_sessions_self_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/account/sessions/self" in rules + + +def test_sessions_self_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsSelfApi + + +def test_account_methods(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account") + assert "GET" in rule.methods + + +def test_sessions_self_methods(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/self") + assert "DELETE" in rule.methods + + +def test_sessions_list_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/account/sessions" in rules + + +def test_sessions_list_dispatches_to_sessions_api(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionsApi + assert "GET" in rule.methods + + +def test_session_by_id_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/account/sessions/" in rules + + +def test_session_by_id_dispatches_to_correct_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/account/sessions/") + assert openapi_app.view_functions[rule.endpoint].view_class is AccountSessionByIdApi + assert "DELETE" in rule.methods + + +def test_subject_match_for_account_filters_by_account_id(): + """Account subject scopes queries via account_id.""" + import uuid as _uuid + + from libs.oauth_bearer import AuthContext, SubjectType + from services.oauth_device_flow import subject_match_clauses + + aid = _uuid.uuid4() + ctx = AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email="user@example.com", + subject_issuer="dify:account", + account_id=aid, + client_id="difyctl", + scopes=frozenset({"full"}), + token_id=_uuid.uuid4(), + source="oauth_account", + expires_at=None, + token_hash="h1", + verified_tenants={}, + ) + clauses = subject_match_clauses(ctx) + # One predicate, on account_id + assert len(clauses) == 1 + assert "account_id" in str(clauses[0]) + + +def test_subject_match_for_external_sso_filters_by_email_and_issuer(): + """External SSO subject scopes via (subject_email, subject_issuer) + AND account_id IS NULL — so a same-email account row from a + federated tenant cannot be revoked through an SSO bearer. + """ + import uuid as _uuid + + from libs.oauth_bearer import AuthContext, SubjectType + from services.oauth_device_flow import subject_match_clauses + + ctx = AuthContext( + subject_type=SubjectType.EXTERNAL_SSO, + subject_email="sso@partner.com", + subject_issuer="https://idp.partner.com", + account_id=None, + client_id="difyctl", + scopes=frozenset({"apps:run"}), + token_id=_uuid.uuid4(), + source="oauth_external_sso", + expires_at=None, + token_hash="h1", + verified_tenants={}, + ) + clauses = subject_match_clauses(ctx) + assert len(clauses) == 3 + rendered = " ".join(str(c) for c in clauses) + assert "subject_email" in rendered + assert "subject_issuer" in rendered + assert "account_id IS NULL" in rendered diff --git a/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py b/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py new file mode 100644 index 0000000000..a6abdc95eb --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_describe_query.py @@ -0,0 +1,48 @@ +"""Unit tests for AppDescribeQuery (`?fields=` allow-list).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from controllers.openapi.apps import AppDescribeQuery + + +def test_no_fields_returns_none() -> None: + q = AppDescribeQuery.model_validate({}) + assert q.fields is None + + +def test_empty_string_returns_none() -> None: + q = AppDescribeQuery.model_validate({"fields": ""}) + assert q.fields is None + + +def test_single_field() -> None: + q = AppDescribeQuery.model_validate({"fields": "info"}) + assert q.fields == {"info"} + + +def test_comma_list() -> None: + q = AppDescribeQuery.model_validate({"fields": "info,parameters"}) + assert q.fields == {"info", "parameters"} + + +def test_whitespace_tolerant() -> None: + q = AppDescribeQuery.model_validate({"fields": " info , input_schema "}) + assert q.fields == {"info", "input_schema"} + + +def test_unknown_member_rejected() -> None: + with pytest.raises(ValidationError): + AppDescribeQuery.model_validate({"fields": "garbage"}) + + +def test_unknown_among_known_rejected() -> None: + with pytest.raises(ValidationError): + AppDescribeQuery.model_validate({"fields": "info,garbage"}) + + +def test_extra_param_forbidden() -> None: + with pytest.raises(ValidationError): + AppDescribeQuery.model_validate({"fields": "info", "page": "1"}) diff --git a/api/tests/unit_tests/controllers/openapi/test_app_list_query.py b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py new file mode 100644 index 0000000000..f7e8e9c73a --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_list_query.py @@ -0,0 +1,105 @@ +"""Unit tests for AppListQuery — the /apps query-param validator. + +Runs against the model directly, not the HTTP layer. Pins: +- defaults match the plan (page=1, limit=20). +- workspace_id is required. +- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]). +- mode validates against the AppMode enum. +- name and tag have length caps. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from controllers.openapi._models import MAX_PAGE_LIMIT +from controllers.openapi.apps import AppListQuery + + +def test_defaults(): + q = AppListQuery.model_validate({"workspace_id": "ws-1"}) + assert q.workspace_id == "ws-1" + assert q.page == 1 + assert q.limit == 20 + assert q.mode is None + assert q.name is None + assert q.tag is None + + +def test_workspace_id_required(): + with pytest.raises(ValidationError): + AppListQuery.model_validate({}) + + +def test_page_must_be_positive(): + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "page": 0}) + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "page": -1}) + + +def test_page_rejects_non_integer_string(): + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "page": "abc"}) + + +def test_limit_must_be_positive(): + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "limit": 0}) + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "limit": -1}) + + +def test_limit_caps_at_max_page_limit(): + # Boundary accepts. + q = AppListQuery.model_validate({"workspace_id": "ws-1", "limit": MAX_PAGE_LIMIT}) + assert q.limit == MAX_PAGE_LIMIT + + # Just over rejects. + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "limit": MAX_PAGE_LIMIT + 1}) + + +def test_mode_whitelisted_against_app_mode(): + # Valid mode passes. + q = AppListQuery.model_validate({"workspace_id": "ws-1", "mode": "chat"}) + assert q.mode is not None + assert q.mode.value == "chat" + + # Invalid mode rejects. + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "mode": "not-a-mode"}) + + +def test_name_length_capped(): + AppListQuery.model_validate({"workspace_id": "ws-1", "name": "x" * 200}) + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "name": "x" * 201}) + + +def test_tag_length_capped(): + AppListQuery.model_validate({"workspace_id": "ws-1", "tag": "x" * 100}) + with pytest.raises(ValidationError): + AppListQuery.model_validate({"workspace_id": "ws-1", "tag": "x" * 101}) + + +def test_all_fields_accept_valid_values(): + """Pin the happy-path acceptance for every field in one place.""" + q = AppListQuery.model_validate( + { + "workspace_id": "ws-1", + "page": 5, + "limit": 50, + "mode": "workflow", + "name": "search", + "tag": "prod", + } + ) + assert q.workspace_id == "ws-1" + assert q.page == 5 + assert q.limit == 50 + assert q.mode is not None + assert q.mode.value == "workflow" + assert q.name == "search" + assert q.tag == "prod" diff --git a/api/tests/unit_tests/controllers/openapi/test_app_payloads.py b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py new file mode 100644 index 0000000000..64cdc38250 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_payloads.py @@ -0,0 +1,55 @@ +"""Unit tests for app payload-rendering helpers — independent of +HTTP plumbing or DB. Pin the response shapes that are CLI contracts. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from controllers.openapi.apps import ( # pyright: ignore[reportPrivateUsage] + _EMPTY_PARAMETERS, + parameters_payload, +) +from controllers.service_api.app.error import AppUnavailableError + + +def _fake_app(**overrides): + base = { + "id": "app1", + "name": "X", + "description": "d", + "mode": "chat", + "author_name": "alice", + "tags": [SimpleNamespace(name="prod")], + "updated_at": None, + "enable_api": True, + "workflow": None, + "app_model_config": None, + } + base.update(overrides) + return SimpleNamespace(**base) + + +def test_parameters_payload_raises_app_unavailable_when_no_config(): + with pytest.raises(AppUnavailableError): + parameters_payload(_fake_app(mode="chat", app_model_config=None)) + + +def test_empty_parameters_constant_matches_describe_fallback_shape(): + """The fallback dict served by /describe when an app has no config + must match the spec's stated keys (opening_statement, suggested_questions, + user_input_form, file_upload, system_parameters).""" + assert set(_EMPTY_PARAMETERS.keys()) == { + "opening_statement", + "suggested_questions", + "user_input_form", + "file_upload", + "system_parameters", + } + assert _EMPTY_PARAMETERS["suggested_questions"] == [] + assert _EMPTY_PARAMETERS["user_input_form"] == [] + assert _EMPTY_PARAMETERS["opening_statement"] is None + assert _EMPTY_PARAMETERS["file_upload"] is None + assert _EMPTY_PARAMETERS["system_parameters"] == {} diff --git a/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py b/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py new file mode 100644 index 0000000000..11d1fc31b5 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_dispatch.py @@ -0,0 +1,32 @@ +import pytest + +from controllers.openapi.app_run import ( + _DISPATCH, + AppRunRequest, +) +from models.model import AppMode + + +def test_dispatch_covers_runnable_modes(): + runnable = {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.COMPLETION, AppMode.WORKFLOW} + assert set(_DISPATCH) == runnable + + +def test_app_run_request_strips_blank_conversation_id(): + payload = AppRunRequest(inputs={}, conversation_id=" ") + assert payload.conversation_id is None + + +def test_app_run_request_rejects_invalid_uuid_conversation_id(): + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="conversation_id must be a valid UUID"): + AppRunRequest(inputs={}, conversation_id="not-a-uuid") + + +def test_app_run_request_accepts_valid_uuid_conversation_id(): + import uuid as _uuid + + cid = str(_uuid.uuid4()) + payload = AppRunRequest(inputs={}, conversation_id=cid) + assert payload.conversation_id == cid 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 new file mode 100644 index 0000000000..8db5033704 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_run_streaming.py @@ -0,0 +1,85 @@ +"""Tests: openapi /run always streams; response_mode removed from AppRunRequest.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from unittest.mock import Mock + +from controllers.openapi._models import AppRunRequest + + +def test_app_run_request_has_no_response_mode_field(): + """response_mode must not be a declared field.""" + assert "response_mode" not in AppRunRequest.model_fields + + +def test_app_run_request_ignores_response_mode_in_payload(): + """Sending response_mode in JSON body is silently ignored (Pydantic extra='ignore').""" + req = AppRunRequest.model_validate({"inputs": {}, "response_mode": "blocking"}) + assert not hasattr(req, "response_mode") + + +def test_app_run_request_valid_minimal(): + req = AppRunRequest.model_validate({"inputs": {}}) + assert req.inputs == {} + + +def test_app_run_request_with_query(): + req = AppRunRequest.model_validate({"inputs": {}, "query": "hello"}) + assert req.query == "hello" + + +def test_run_chat_always_calls_generate_with_streaming_true(app, bypass_pipeline, monkeypatch): + """_run_chat must always invoke AppGenerateService.generate with streaming=True.""" + from controllers.openapi.app_run import _run_chat + + generate_mock = Mock(return_value=iter([])) + monkeypatch.setattr( + sys.modules["controllers.openapi.app_run"], + "AppGenerateService", + SimpleNamespace(generate=generate_mock), + ) + with app.test_request_context("/openapi/v1/apps/app-1/run", method="POST"): + _run_chat( + SimpleNamespace(id="app-1", tenant_id="t-1"), + SimpleNamespace(id="acct-1"), + AppRunRequest(inputs={}, query="hello"), + ) + _, kwargs = generate_mock.call_args + assert kwargs["streaming"] is True + + +def test_stop_task_endpoint_registered(openapi_app): + """POST /openapi/v1/apps//tasks//stop must be registered.""" + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/apps//tasks//stop" in rules + + +def test_stop_task_calls_queue_manager_and_graph_engine(app, bypass_pipeline, monkeypatch): + from controllers.openapi.app_run import AppRunTaskStopApi + + queue_mock = Mock() + graph_mock = Mock() + graph_instance = Mock() + graph_mock.return_value = graph_instance + + run_module = sys.modules["controllers.openapi.app_run"] + monkeypatch.setattr(run_module, "AppQueueManager", queue_mock) + monkeypatch.setattr(run_module, "GraphEngineManager", graph_mock) + monkeypatch.setattr(run_module, "redis_client", object()) + + api = AppRunTaskStopApi() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1/stop", method="POST"): + result = api.post.__wrapped__( + api, + app_id="app-1", + task_id="task-1", + app_model=SimpleNamespace(id="app-1", tenant_id="t-1"), + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1") + graph_instance.send_stop_command.assert_called_once_with("task-1") + assert result == {"result": "success"} diff --git a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py new file mode 100644 index 0000000000..96873b04f4 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_external_query.py @@ -0,0 +1,53 @@ +"""Unit tests for PermittedExternalAppsListQuery — the +/permitted-external-apps query validator. + +Strict ConfigDict(extra='forbid'): cross-tenant tag/workspace_id are +unresolvable, so the model must reject them as 422 instead of silently +dropping them. Mode/name/page/limit have the same shape as AppListQuery. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from controllers.openapi.apps_permitted_external import PermittedExternalAppsListQuery + + +def test_query_defaults_match_apps_list(): + q = PermittedExternalAppsListQuery.model_validate({}) + assert q.page == 1 + assert q.limit == 20 + assert q.mode is None + assert q.name is None + + +def test_query_rejects_workspace_id(): + """workspace_id is meaningless for /permitted-external-apps (cross-tenant); + rejecting it forces CLI authors to drop the param rather than send it + silently.""" + with pytest.raises(ValidationError): + PermittedExternalAppsListQuery.model_validate({"workspace_id": "ws-1"}) + + +def test_query_rejects_tag(): + """Tags are tenant-scoped; cross-tenant tag resolution is undefined.""" + with pytest.raises(ValidationError): + PermittedExternalAppsListQuery.model_validate({"tag": "prod"}) + + +def test_query_validates_mode_against_app_mode(): + with pytest.raises(ValidationError): + PermittedExternalAppsListQuery.model_validate({"mode": "not-a-mode"}) + + +def test_query_clamps_limit_at_max(): + with pytest.raises(ValidationError): + PermittedExternalAppsListQuery.model_validate({"limit": 500}) + + +def test_query_accepts_valid_mode(): + """Pin the happy path: AppMode values pass.""" + q = PermittedExternalAppsListQuery.model_validate({"mode": "chat"}) + assert q.mode is not None + assert q.mode.value == "chat" diff --git a/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py b/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py new file mode 100644 index 0000000000..b2a115f955 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_audit_app_run.py @@ -0,0 +1,26 @@ +import logging + +from controllers.openapi._audit import EVENT_APP_RUN_OPENAPI, emit_app_run + + +def test_event_constant(): + assert EVENT_APP_RUN_OPENAPI == "app.run.openapi" + + +def test_emit_app_run_logs_with_audit_extra(caplog): + with caplog.at_level(logging.INFO, logger="controllers.openapi._audit"): + emit_app_run( + app_id="app1", + tenant_id="t1", + caller_kind="account", + mode="chat", + surface="apps", + ) + record = next(r for r in caplog.records if r.message and "app.run.openapi" in r.message) + assert record.audit is True + assert record.event == EVENT_APP_RUN_OPENAPI + assert record.app_id == "app1" + assert record.tenant_id == "t1" + assert record.caller_kind == "account" + assert record.mode == "chat" + assert record.surface == "apps" diff --git a/api/tests/unit_tests/controllers/openapi/test_cors.py b/api/tests/unit_tests/controllers/openapi/test_cors.py new file mode 100644 index 0000000000..895c685da1 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_cors.py @@ -0,0 +1,127 @@ +"""CORS posture for /openapi/v1/* — default empty allowlist (same-origin), +expandable via OPENAPI_CORS_ALLOW_ORIGINS. Cross-origin requests from +disallowed origins do not receive the Access-Control-Allow-Origin +header, which the browser then blocks. + +Tests use a fresh Blueprint + Flask-CORS per case because the production +blueprint is a module-level singleton and can't be reconfigured once +registered. +""" + +import builtins + +from flask import Blueprint, Flask +from flask.views import MethodView +from flask_cors import CORS +from flask_restx import Resource + +from configs import dify_config +from extensions.ext_blueprints import OPENAPI_HEADERS, OPENAPI_MAX_AGE_SECONDS +from libs.external_api import ExternalApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +def _make_app(allowed_origins: list[str], blueprint_name: str) -> Flask: + """Build a Flask app with a fresh openapi-style blueprint mirroring + production CORS settings, parameterised on the origin allowlist. + """ + bp = Blueprint(blueprint_name, __name__, url_prefix="/openapi/v1") + api = ExternalApi(bp, version="1.0", title="OpenAPI Test", description="") + + @api.route("/_health") + class _Health(Resource): + def get(self): + return {"ok": True} + + CORS( + bp, + resources={r"/*": {"origins": allowed_origins}}, + supports_credentials=True, + allow_headers=list(OPENAPI_HEADERS), + methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + expose_headers=["X-Version"], + max_age=OPENAPI_MAX_AGE_SECONDS, + ) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(bp) + return app + + +def test_default_openapi_cors_allowlist_is_empty(): + """Default config admits no cross-origin until operator opts in.""" + assert dify_config.OPENAPI_CORS_ALLOW_ORIGINS == [] + + +def test_preflight_allowed_origin_returns_cors_headers(): + app = _make_app(["https://app.example.com"], "openapi_t1") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + + assert response.headers.get("Access-Control-Allow-Origin") == "https://app.example.com" + assert response.headers.get("Access-Control-Max-Age") == str(OPENAPI_MAX_AGE_SECONDS) + + +def test_preflight_disallowed_origin_omits_cors_headers(): + app = _make_app(["https://app.example.com"], "openapi_t2") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://attacker.example", + "Access-Control-Request-Method": "GET", + }, + ) + + # flask-cors omits Allow-Origin for disallowed origins; browser blocks. + assert "Access-Control-Allow-Origin" not in response.headers + + +def test_preflight_with_default_empty_allowlist_omits_cors_headers(): + app = _make_app([], "openapi_t3") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + + assert "Access-Control-Allow-Origin" not in response.headers + + +def test_same_origin_request_succeeds_without_origin_header(): + app = _make_app(["https://app.example.com"], "openapi_t4") + client = app.test_client() + # Browsers don't send Origin on same-origin GETs. + response = client.get("/openapi/v1/_health") + + assert response.status_code == 200 + assert response.get_json() == {"ok": True} + + +def test_authorization_header_is_in_allow_headers(): + """Bearer-authed routes need Authorization in the preflight response.""" + app = _make_app(["https://app.example.com"], "openapi_t5") + client = app.test_client() + response = client.options( + "/openapi/v1/_health", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Authorization", + }, + ) + + allow_headers = response.headers.get("Access-Control-Allow-Headers", "").lower() + assert "authorization" in allow_headers diff --git a/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py b/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py new file mode 100644 index 0000000000..dbe2f7bfae --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_approve_deny.py @@ -0,0 +1,52 @@ +"""Account-branch device-flow approve/deny under /openapi/v1.""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device import DeviceApproveApi, DeviceDenyApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +def test_approve_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/approve" in rules + + +def test_deny_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/deny" in rules + + +def test_approve_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approve") + assert openapi_app.view_functions[rule.endpoint].view_class is DeviceApproveApi + + +def test_deny_dispatches_to_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/deny") + assert openapi_app.view_functions[rule.endpoint].view_class is DeviceDenyApi + + +def test_approve_and_deny_methods(openapi_app: Flask): + approve = _rule(openapi_app, "/openapi/v1/oauth/device/approve") + deny = _rule(openapi_app, "/openapi/v1/oauth/device/deny") + assert "POST" in approve.methods + assert "POST" in deny.methods diff --git a/api/tests/unit_tests/controllers/openapi/test_device_code.py b/api/tests/unit_tests/controllers/openapi/test_device_code.py new file mode 100644 index 0000000000..821a423805 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_code.py @@ -0,0 +1,47 @@ +"""POST /openapi/v1/oauth/device/code is the canonical RFC 8628 device +authorization endpoint. + +Tests verify URL routing without invoking the handler — invoking would +require Redis, which the unit-test runtime does not initialise. +""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device import OAuthDeviceCodeApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/code" in rules + + +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code") + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceCodeApi + + +def test_route_accepts_post(openapi_app: Flask): + rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/code") + assert "POST" in rule.methods + + +def test_known_client_ids_default_includes_difyctl(): + from configs import dify_config + + assert "difyctl" in dify_config.OPENAPI_KNOWN_CLIENT_IDS diff --git a/api/tests/unit_tests/controllers/openapi/test_device_lookup.py b/api/tests/unit_tests/controllers/openapi/test_device_lookup.py new file mode 100644 index 0000000000..5907378a73 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_lookup.py @@ -0,0 +1,36 @@ +"""GET /openapi/v1/oauth/device/lookup is the canonical user-code lookup.""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device import OAuthDeviceLookupApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/lookup" in rules + + +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup") + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceLookupApi + + +def test_route_accepts_get(openapi_app: Flask): + rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/lookup") + assert "GET" in rule.methods diff --git a/api/tests/unit_tests/controllers/openapi/test_device_sso.py b/api/tests/unit_tests/controllers/openapi/test_device_sso.py new file mode 100644 index 0000000000..0125c583f0 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_sso.py @@ -0,0 +1,79 @@ +"""SSO-branch device-flow endpoints under /openapi/v1/oauth/device/.""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device_sso import ( + approval_context, + approve_external, + sso_complete, + sso_initiate, +) + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +def test_sso_initiate_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/sso-initiate" in rules + + +def test_sso_complete_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/sso-complete" in rules + + +def test_approval_context_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/approval-context" in rules + + +def test_approve_external_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/approve-external" in rules + + +def test_sso_initiate_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/sso-initiate") + assert openapi_app.view_functions[rule.endpoint] is sso_initiate + + +def test_sso_complete_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/sso-complete") + assert openapi_app.view_functions[rule.endpoint] is sso_complete + + +def test_approval_context_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approval-context") + assert openapi_app.view_functions[rule.endpoint] is approval_context + + +def test_approve_external_dispatches_to_function(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/oauth/device/approve-external") + assert openapi_app.view_functions[rule.endpoint] is approve_external + + +def test_sso_complete_idp_callback_url_uses_canonical_path(): + """sso_initiate hardcodes the IdP callback URL — must point at the + canonical /openapi/v1/ path so IdP-side ACS configuration matches. + """ + from controllers.openapi import oauth_device_sso + + assert oauth_device_sso._SSO_COMPLETE_PATH == "/openapi/v1/oauth/device/sso-complete" diff --git a/api/tests/unit_tests/controllers/openapi/test_device_token.py b/api/tests/unit_tests/controllers/openapi/test_device_token.py new file mode 100644 index 0000000000..8b83068856 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_device_token.py @@ -0,0 +1,31 @@ +"""POST /openapi/v1/oauth/device/token is the canonical poll endpoint.""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.oauth_device import OAuthDeviceTokenApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def test_openapi_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/oauth/device/token" in rules + + +def test_route_dispatches_to_class(openapi_app: Flask): + rule = next(r for r in openapi_app.url_map.iter_rules() if r.rule == "/openapi/v1/oauth/device/token") + assert openapi_app.view_functions[rule.endpoint].view_class is OAuthDeviceTokenApi diff --git a/api/tests/unit_tests/controllers/openapi/test_health.py b/api/tests/unit_tests/controllers/openapi/test_health.py new file mode 100644 index 0000000000..f59e4d9a97 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_health.py @@ -0,0 +1,33 @@ +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def test_health_returns_ok(app: Flask): + client = app.test_client() + response = client.get("/openapi/v1/_health") + + assert response.status_code == 200 + assert response.get_json() == {"ok": True} + + +def test_health_path_is_under_openapi_v1_prefix(app: Flask): + client = app.test_client() + assert client.get("/_health").status_code == 404 + assert client.get("/v1/_health").status_code == 404 + assert client.get("/openapi/v1/_health").status_code == 200 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 new file mode 100644 index 0000000000..42ecfc5eb2 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_human_input_form.py @@ -0,0 +1,227 @@ +"""Tests for openapi human input form endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from models.human_input import RecipientType + + +class TestOpenApiHumanInputFormGet: + def test_get_success(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + definition = SimpleNamespace( + model_dump=lambda: { + "rendered_content": "Fill out the form", + "inputs": [{"output_variable_name": "field1"}], + "default_values": {"field1": "default"}, + "user_actions": [{"id": "submit", "title": "Submit"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + service_mock.ensure_form_active = Mock() + + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + resp = api.get.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + payload = json.loads(resp.get_data(as_text=True)) + assert payload["form_content"] == "Fill out the form" + assert payload["resolved_default_values"] == {"field1": "default"} + assert payload["user_actions"] == [{"id": "submit", "title": "Submit"}] + service_mock.ensure_form_active.assert_called_once_with(form) + + def test_get_form_not_found(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + service_mock = Mock() + service_mock.get_form_by_token.return_value = None + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + form_token="bad", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + def test_get_form_wrong_app(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + form = SimpleNamespace( + app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC) + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + def test_get_form_wrong_surface(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.CONSOLE, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + +class TestOpenApiHumanInputFormPost: + def _make_form(self, app_id="app-1", recipient_type=RecipientType.STANDALONE_WEB_APP): + return SimpleNamespace( + app_id=app_id, + tenant_id="tenant-1", + recipient_type=recipient_type, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + + def test_post_account_caller_uses_user_id(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + form = self._make_form() + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + caller = SimpleNamespace(id="acct-42") + + with app.test_request_context( + "/openapi/v1/apps/app-1/form/human_input/tok-1", + method="POST", + json={"action": "approve", "inputs": {"field1": "val"}}, + ): + result = api.post.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + app_model=app_model, + caller=caller, + caller_kind="account", + ) + + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="tok-1", + selected_action_id="approve", + form_data={"field1": "val"}, + submission_user_id="acct-42", + submission_end_user_id=None, + ) + assert result == ({}, 200) + + def test_post_end_user_caller_uses_end_user_id(self, app, bypass_pipeline, monkeypatch): + from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi + + form = self._make_form() + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + + module = sys.modules["controllers.openapi.human_input_form"] + monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = OpenApiWorkflowHumanInputFormApi() + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + caller = SimpleNamespace(id="eu-7") + + with app.test_request_context( + "/openapi/v1/apps/app-1/form/human_input/tok-1", + method="POST", + json={"action": "approve", "inputs": {}}, + ): + result = api.post.__wrapped__( + api, + app_id="app-1", + form_token="tok-1", + app_model=app_model, + caller=caller, + caller_kind="end_user", + ) + + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="tok-1", + selected_action_id="approve", + form_data={}, + submission_user_id=None, + submission_end_user_id="eu-7", + ) + assert result == ({}, 200) diff --git a/api/tests/unit_tests/controllers/openapi/test_input_schema.py b/api/tests/unit_tests/controllers/openapi/test_input_schema.py new file mode 100644 index 0000000000..73cb978ac1 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_input_schema.py @@ -0,0 +1,182 @@ +"""Unit tests for input_schema derivation.""" + +from __future__ import annotations + +import pytest + +from controllers.openapi._input_schema import _form_to_jsonschema + + +def _wrap(component: dict) -> list[dict]: + """user_input_form rows are single-key dicts: {"text-input": {...}}.""" + return [component] + + +def test_text_input_required() -> None: + form = _wrap({"text-input": {"variable": "industry", "label": "Industry", "required": True, "max_length": 200}}) + props, required = _form_to_jsonschema(form) + assert props == {"industry": {"type": "string", "title": "Industry", "maxLength": 200}} + assert required == ["industry"] + + +def test_paragraph_optional() -> None: + form = _wrap({"paragraph": {"variable": "context", "label": "Context", "required": False, "max_length": 4000}}) + props, required = _form_to_jsonschema(form) + assert props["context"] == {"type": "string", "title": "Context", "maxLength": 4000} + assert required == [] + + +def test_select_enum() -> None: + form = _wrap( + { + "select": { + "variable": "tier", + "label": "Tier", + "required": True, + "options": ["free", "pro", "enterprise"], + } + } + ) + props, required = _form_to_jsonschema(form) + assert props == {"tier": {"type": "string", "title": "Tier", "enum": ["free", "pro", "enterprise"]}} + assert required == ["tier"] + + +def test_number() -> None: + form = _wrap({"number": {"variable": "count", "label": "Count", "required": False}}) + props, _required = _form_to_jsonschema(form) + assert props["count"] == {"type": "number", "title": "Count"} + + +def test_file() -> None: + form = _wrap({"file": {"variable": "doc", "label": "Doc", "required": True}}) + props, required = _form_to_jsonschema(form) + assert props["doc"]["type"] == "object" + assert "title" in props["doc"] + assert required == ["doc"] + + +def test_file_list() -> None: + form = _wrap({"file-list": {"variable": "attachments", "label": "Attachments", "required": False}}) + props, _required = _form_to_jsonschema(form) + assert props["attachments"]["type"] == "array" + assert props["attachments"]["items"]["type"] == "object" + + +def test_unknown_type_skipped() -> None: + """Forward-compat: unknown variable types are skipped, not 500'd.""" + form = _wrap({"future-type": {"variable": "x", "label": "X", "required": False}}) + props, required = _form_to_jsonschema(form) + assert props == {} + assert required == [] + + +def test_required_order_preserved() -> None: + form = [ + {"text-input": {"variable": "a", "label": "A", "required": True}}, + {"text-input": {"variable": "b", "label": "B", "required": False}}, + {"text-input": {"variable": "c", "label": "C", "required": True}}, + ] + _props, required = _form_to_jsonschema(form) + assert required == ["a", "c"] + + +def test_max_length_omitted_when_zero() -> None: + form = _wrap({"text-input": {"variable": "x", "label": "X", "required": False, "max_length": 0}}) + props, _ = _form_to_jsonschema(form) + assert "maxLength" not in props["x"] + + +from unittest.mock import MagicMock + +from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema +from controllers.service_api.app.error import AppUnavailableError +from models.model import AppMode + + +def _stub_app(mode: AppMode, *, form: list[dict] | None = None, has_workflow: bool | None = None): + """Returns a MagicMock whose .mode + workflow / app_model_config branch is wired up.""" + app = MagicMock() + app.mode = mode + if mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT): + if has_workflow is False: + app.workflow = None + else: + app.workflow = MagicMock() + app.workflow.user_input_form.return_value = form or [] + app.workflow.features_dict = {} + else: + if has_workflow is False: + app.app_model_config = None + else: + app.app_model_config = MagicMock() + app.app_model_config.to_dict.return_value = {"user_input_form": form or []} + return app + + +def test_chat_mode_includes_query() -> None: + app = _stub_app(AppMode.CHAT, form=[{"text-input": {"variable": "x", "label": "X", "required": True}}]) + schema = build_input_schema(app) + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert "query" in schema["properties"] + assert schema["properties"]["query"]["type"] == "string" + assert schema["properties"]["query"]["minLength"] == 1 + assert "query" in schema["required"] + assert "inputs" in schema["required"] + assert schema["properties"]["inputs"]["additionalProperties"] is False + + +def test_agent_chat_mode_includes_query() -> None: + app = _stub_app(AppMode.AGENT_CHAT, form=[]) + schema = build_input_schema(app) + assert "query" in schema["properties"] + + +def test_advanced_chat_mode_includes_query() -> None: + app = _stub_app(AppMode.ADVANCED_CHAT, form=[]) + schema = build_input_schema(app) + assert "query" in schema["properties"] + + +def test_workflow_mode_omits_query() -> None: + app = _stub_app(AppMode.WORKFLOW, form=[]) + schema = build_input_schema(app) + assert "query" not in schema["properties"] + assert schema["required"] == ["inputs"] + + +def test_completion_mode_omits_query() -> None: + app = _stub_app(AppMode.COMPLETION, form=[]) + schema = build_input_schema(app) + assert "query" not in schema["properties"] + assert schema["required"] == ["inputs"] + + +def test_inputs_required_driven_by_form() -> None: + app = _stub_app( + AppMode.CHAT, + form=[ + {"text-input": {"variable": "industry", "label": "Industry", "required": True}}, + {"text-input": {"variable": "context", "label": "Context", "required": False}}, + ], + ) + schema = build_input_schema(app) + assert schema["properties"]["inputs"]["required"] == ["industry"] + + +def test_misconfigured_chat_raises_app_unavailable() -> None: + app = _stub_app(AppMode.CHAT, has_workflow=False) + with pytest.raises(AppUnavailableError): + build_input_schema(app) + + +def test_misconfigured_workflow_raises_app_unavailable() -> None: + app = _stub_app(AppMode.WORKFLOW, has_workflow=False) + with pytest.raises(AppUnavailableError): + build_input_schema(app) + + +def test_empty_input_schema_sentinel_shape() -> None: + assert EMPTY_INPUT_SCHEMA["type"] == "object" + assert EMPTY_INPUT_SCHEMA["properties"] == {} + assert EMPTY_INPUT_SCHEMA["required"] == [] diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py new file mode 100644 index 0000000000..8f9e1016e8 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -0,0 +1,54 @@ +"""Meta endpoint /openapi/v1/_version — no auth, returns version + edition.""" + +from __future__ import annotations + + +def test_version_endpoint_returns_200_without_auth(openapi_app): + client = openapi_app.test_client() + response = client.get("/openapi/v1/_version") + + assert response.status_code == 200 + payload = response.get_json() + assert isinstance(payload, dict) + assert "version" in payload + assert "edition" in payload + assert isinstance(payload["version"], str) + assert payload["edition"] in ("SELF_HOSTED", "CLOUD") + + +def test_version_endpoint_ignores_bearer_header(openapi_app): + """Endpoint is auth-free — a bogus bearer should not break it.""" + client = openapi_app.test_client() + response = client.get( + "/openapi/v1/_version", + headers={"Authorization": "Bearer total-nonsense"}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert "version" in payload + assert "edition" in payload + + +def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch): + from configs import dify_config + + monkeypatch.setattr(dify_config, "EDITION", "CLOUD") + + client = openapi_app.test_client() + response = client.get("/openapi/v1/_version") + + assert response.status_code == 200 + assert response.get_json()["edition"] == "CLOUD" + + +def test_version_endpoint_falls_back_to_self_hosted_on_unexpected_edition(openapi_app, monkeypatch): + from configs import dify_config + + monkeypatch.setattr(dify_config, "EDITION", "EXPERIMENTAL") + + client = openapi_app.test_client() + response = client.get("/openapi/v1/_version") + + assert response.status_code == 200 + assert response.get_json()["edition"] == "SELF_HOSTED" diff --git a/api/tests/unit_tests/controllers/openapi/test_models.py b/api/tests/unit_tests/controllers/openapi/test_models.py new file mode 100644 index 0000000000..d29b592f6a --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_models.py @@ -0,0 +1,31 @@ +from controllers.openapi._models import MessageMetadata, UsageInfo + + +def test_usage_info_defaults_zero(): + u = UsageInfo() + assert u.prompt_tokens == 0 + assert u.completion_tokens == 0 + assert u.total_tokens == 0 + + +def test_message_metadata_accepts_partial(): + m = MessageMetadata(usage=UsageInfo(total_tokens=10)) + assert m.usage.total_tokens == 10 + assert m.retriever_resources == [] + + +def test_describe_response_all_blocks_optional() -> None: + from controllers.openapi._models import AppDescribeResponse + + payload = AppDescribeResponse().model_dump(mode="json", exclude_none=False) + assert payload == {"info": None, "parameters": None, "input_schema": None} + + +def test_describe_response_input_schema_field() -> None: + from controllers.openapi._models import AppDescribeResponse + + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object"} + payload = AppDescribeResponse(input_schema=schema).model_dump(mode="json", exclude_none=False) + assert payload["input_schema"] == schema + assert payload["info"] is None + assert payload["parameters"] is None diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py new file mode 100644 index 0000000000..9eb4cf01f4 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from flask import Flask + +from controllers.openapi import bp as openapi_bp + + +@pytest.fixture +def app() -> Flask: + a = Flask(__name__) + a.config["TESTING"] = True + a.register_blueprint(openapi_bp) + return a + + +def _ee_features(): + from services.feature_service import LicenseStatus + + m = MagicMock() + m.license.status = LicenseStatus.ACTIVE + return m + + +@patch("controllers.openapi.oauth_device_sso.jws") +@patch("libs.device_flow_security.FeatureService.get_system_features") +def test_sso_complete_rejects_assertion_missing_email(ee_feat, jws_mod, app: Flask): + ee_feat.return_value = _ee_features() + jws_mod.verify.return_value = {"issuer": "https://idp.example", "user_code": "ABCD-EFGH", "nonce": "n"} + jws_mod.AUD_EXT_SUBJECT_ASSERTION = "aud" + jws_mod.KeySet.from_shared_secret.return_value = object() + jws_mod.VerifyError = Exception + + client = app.test_client() + resp = client.get("/openapi/v1/oauth/device/sso-complete?sso_assertion=blob") + assert resp.status_code == 400, resp.data + + +@patch("controllers.openapi.oauth_device_sso.jws") +@patch("libs.device_flow_security.FeatureService.get_system_features") +def test_sso_complete_rejects_assertion_empty_issuer(ee_feat, jws_mod, app: Flask): + ee_feat.return_value = _ee_features() + jws_mod.verify.return_value = {"email": "x@y.com", "issuer": "", "user_code": "ABCD-EFGH", "nonce": "n"} + jws_mod.AUD_EXT_SUBJECT_ASSERTION = "aud" + jws_mod.KeySet.from_shared_secret.return_value = object() + jws_mod.VerifyError = Exception + + client = app.test_client() + resp = client.get("/openapi/v1/oauth/device/sso-complete?sso_assertion=blob") + assert resp.status_code == 400 + + +def test_verify_approval_grant_raises_on_missing_field(): + from libs import device_flow_security + from libs import jws as jws_mod + + class _FakeKeyset: + active_kid = "k" + + def lookup(self, kid): + return b"secret" + + keyset = _FakeKeyset() + incomplete = jws_mod.sign( + keyset, + payload={"subject_email": "x@y.com", "subject_issuer": "i", "user_code": "ABCD-EFGH", "nonce": "n"}, + aud=jws_mod.AUD_APPROVAL_GRANT, + ttl_seconds=60, + ) + with pytest.raises(jws_mod.VerifyError): + device_flow_security.verify_approval_grant(keyset, incomplete) diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_csrf.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_csrf.py new file mode 100644 index 0000000000..e31a308506 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_csrf.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +def _repo_root() -> Path: + for parent in Path(__file__).resolve().parents: + if (parent / "api" / "pyproject.toml").exists(): + return parent + raise RuntimeError("repo root not found") + + +def test_approve_external_uses_compare_digest_for_csrf(): + src = (_repo_root() / "api" / "controllers" / "openapi" / "oauth_device_sso.py").read_text() + tree = ast.parse(src) + + fn = next(n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == "approve_external") + fn_src = ast.unparse(fn) + + assert "compare_digest" in fn_src, "approve_external must call secrets.compare_digest for CSRF" + assert "csrf_header != claims.csrf_token" not in fn_src, "approve_external must not use plain != on csrf_token" diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py new file mode 100644 index 0000000000..2eff2d2541 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch +from urllib.parse import urlparse + +import pytest +from flask import Flask + +from controllers.openapi import bp as openapi_bp + + +@pytest.fixture +def app() -> Flask: + a = Flask(__name__) + a.config["TESTING"] = True + a.register_blueprint(openapi_bp) + return a + + +def _ee_features(): + from services.feature_service import LicenseStatus + + m = MagicMock() + m.license.status = LicenseStatus.ACTIVE + return m + + +@patch("controllers.openapi.oauth_device_sso.EnterpriseService") +@patch("controllers.openapi.oauth_device_sso.jws") +@patch("controllers.openapi.oauth_device_sso.DeviceFlowRedis") +@patch("controllers.openapi.oauth_device_sso.dify_config") +@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.rate_limit.RateLimiter.is_rate_limited", new=MagicMock(return_value=False)) +@patch("libs.rate_limit.RateLimiter.increment_rate_limit", new=MagicMock()) +def test_idp_callback_url_uses_console_api_url_not_host_header(ee_feat, cfg, redis_cls, jws_mod, ent, app: Flask): + ee_feat.return_value = _ee_features() + cfg.CONSOLE_API_URL = "https://api.dify.example" + state = MagicMock() + from services.oauth_device_flow import DeviceFlowStatus + + state.status = DeviceFlowStatus.PENDING + redis_cls.return_value.load_by_user_code.return_value = ("dc_x", state) + jws_mod.KeySet.from_shared_secret.return_value = MagicMock() + jws_mod.sign.return_value = "signed-state" + jws_mod.AUD_STATE_ENVELOPE = "aud" + ent.initiate_device_flow_sso.return_value = {"url": "https://idp.example/auth"} + + client = app.test_client() + client.get( + "/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-EFGH", + headers={"Host": "evil.com"}, + ) + + args, kwargs = jws_mod.sign.call_args + signed_payload = args[1] if len(args) > 1 else kwargs["payload"] + callback_url = urlparse(signed_payload["idp_callback_url"]) + assert callback_url.scheme == "https" + assert callback_url.hostname == "api.dify.example" + assert "evil.com" not in signed_payload["idp_callback_url"] + + +@patch("controllers.openapi.oauth_device_sso.DeviceFlowRedis") +@patch("controllers.openapi.oauth_device_sso.dify_config") +@patch("libs.device_flow_security.FeatureService.get_system_features") +@patch("libs.rate_limit.RateLimiter.is_rate_limited", new=MagicMock(return_value=False)) +@patch("libs.rate_limit.RateLimiter.increment_rate_limit", new=MagicMock()) +def test_sso_initiate_fails_closed_when_console_api_url_unset(ee_feat, cfg, redis_cls, app: Flask): + ee_feat.return_value = _ee_features() + cfg.CONSOLE_API_URL = "" + from services.oauth_device_flow import DeviceFlowStatus + + state = MagicMock() + state.status = DeviceFlowStatus.PENDING + redis_cls.return_value.load_by_user_code.return_value = ("dc_x", state) + + client = app.test_client() + resp = client.get("/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-EFGH") + assert resp.status_code == 502 diff --git a/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py new file mode 100644 index 0000000000..930647608f --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_pagination_envelope.py @@ -0,0 +1,140 @@ +"""Unit tests for PaginationEnvelope generic Pydantic model.""" + +from __future__ import annotations + +from pydantic import BaseModel + +from controllers.openapi._models import PaginationEnvelope + + +class _Row(BaseModel): + id: str + name: str + + +def test_envelope_basic_fields(): + env = PaginationEnvelope[_Row](page=1, limit=20, total=42, has_more=True, data=[_Row(id="a", name="A")]) + dumped = env.model_dump(mode="json") + assert dumped == { + "page": 1, + "limit": 20, + "total": 42, + "has_more": True, + "data": [{"id": "a", "name": "A"}], + } + + +def test_envelope_empty_data_no_more(): + env = PaginationEnvelope[_Row](page=1, limit=20, total=0, has_more=False, data=[]) + assert env.model_dump(mode="json")["data"] == [] + assert env.model_dump(mode="json")["has_more"] is False + + +def test_envelope_has_more_true_when_total_exceeds_page_window(): + env = PaginationEnvelope[_Row].build(page=1, limit=20, total=42, items=[_Row(id="a", name="A")]) + assert env.has_more is True + + +def test_envelope_has_more_false_when_total_within_page_window(): + env = PaginationEnvelope[_Row].build(page=2, limit=20, total=22, items=[_Row(id="a", name="A")]) + assert env.has_more is False + + +def test_envelope_has_more_false_for_last_page(): + env = PaginationEnvelope[_Row].build(page=3, limit=20, total=42, items=[_Row(id="a", name="A")]) + assert env.has_more is False + + +def test_max_page_limit_is_200(): + from controllers.openapi._models import MAX_PAGE_LIMIT + + assert MAX_PAGE_LIMIT == 200 + + +def test_envelope_uses_pep695_generics(): + """Verify the class uses PEP 695 native generic syntax (not legacy Generic[T]).""" + from controllers.openapi._models import PaginationEnvelope + + # PEP 695 syntax populates __type_params__; the legacy Generic[T] form does not. + assert PaginationEnvelope.__type_params__, "expected PEP 695 native generic syntax" + + fields = PaginationEnvelope.model_fields + assert {"page", "limit", "total", "has_more", "data"} <= set(fields) + + +def test_app_info_response_dump_matches_spec(): + from controllers.openapi._models import AppInfoResponse + + obj = AppInfoResponse( + id="app1", + name="X", + description="d", + mode="chat", + author="alice", + tags=[{"name": "prod"}], + ) + assert obj.model_dump(mode="json") == { + "id": "app1", + "name": "X", + "description": "d", + "mode": "chat", + "author": "alice", + "tags": [{"name": "prod"}], + } + + +def test_app_describe_response_nests_info_and_parameters(): + from controllers.openapi._models import AppDescribeInfo, AppDescribeResponse + + info = AppDescribeInfo( + id="app1", + name="X", + mode="chat", + description=None, + tags=[], + author=None, + updated_at="2026-05-05T00:00:00+00:00", + service_api_enabled=True, + ) + obj = AppDescribeResponse(info=info, parameters={"opening_statement": None}) + dumped = obj.model_dump(mode="json") + assert dumped["info"]["service_api_enabled"] is True + assert dumped["parameters"]["opening_statement"] is None + + +def test_response_models_dump_per_mode(): + from controllers.openapi._models import ( + ChatMessageResponse, + CompletionMessageResponse, + WorkflowRunData, + WorkflowRunResponse, + ) + + chat = ChatMessageResponse( + event="message", + task_id="t1", + id="m1", + message_id="m1", + conversation_id="c1", + mode="chat", + answer="hi", + created_at=0, + ) + assert chat.model_dump(mode="json")["mode"] == "chat" + wf = WorkflowRunResponse( + workflow_run_id="r1", + task_id="t1", + data=WorkflowRunData(id="r1", workflow_id="w1", status="succeeded"), + ) + assert wf.model_dump(mode="json")["data"]["status"] == "succeeded" + assert wf.model_dump(mode="json")["mode"] == "workflow" + comp = CompletionMessageResponse( + event="message", + task_id="t2", + id="m2", + message_id="m2", + mode="completion", + answer="ok", + created_at=0, + ) + assert comp.model_dump(mode="json")["mode"] == "completion" diff --git a/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py new file mode 100644 index 0000000000..78b85460b3 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_workflow_events_openapi.py @@ -0,0 +1,239 @@ +"""Tests for openapi workflow events reconnect endpoint.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from models.enums import CreatorUserRole + + +def _make_workflow_run( + *, + app_id="app-1", + tenant_id="tenant-1", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="acct-1", + finished_at=None, +): + return SimpleNamespace( + id="wf-run-1", + app_id=app_id, + tenant_id=tenant_id, + created_by_role=created_by_role, + created_by=created_by, + finished_at=finished_at, + ) + + +class TestOpenApiWorkflowEventsApi: + def _get_api(self): + from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi + + return OpenApiWorkflowEventsApi() + + def test_not_found_when_run_missing(self, app, bypass_pipeline, monkeypatch): + module = sys.modules["controllers.openapi.workflow_events"] + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = None + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = self._get_api() + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + def test_not_found_when_run_belongs_to_different_app(self, app, bypass_pipeline, monkeypatch): + module = sys.modules["controllers.openapi.workflow_events"] + run = _make_workflow_run(app_id="other-app") + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + api = self._get_api() + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + def test_account_caller_checks_created_by_account(self, app, bypass_pipeline, monkeypatch): + """Account caller must match created_by == caller.id and role == ACCOUNT.""" + module = sys.modules["controllers.openapi.workflow_events"] + run = _make_workflow_run(created_by_role=CreatorUserRole.ACCOUNT, created_by="acct-1") + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + snapshot_builder = Mock(return_value=iter([])) + monkeypatch.setattr(module, "build_workflow_event_stream", snapshot_builder) + + generator_mock = Mock() + generator_mock.convert_to_event_stream.return_value = iter([]) + monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) + + msg_gen_mock = Mock() + msg_gen_mock.retrieve_events.return_value = iter([]) + monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) + + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + api = self._get_api() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + # Should not raise NotFound for matching caller + resp = api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + assert resp.mimetype == "text/event-stream" + + def test_account_caller_rejected_for_end_user_run(self, app, bypass_pipeline, monkeypatch): + module = sys.modules["controllers.openapi.workflow_events"] + run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + api = self._get_api() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + with pytest.raises(NotFound): + api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + + def test_end_user_caller_checks_created_by_end_user(self, app, bypass_pipeline, monkeypatch): + """End-user caller must match created_by == caller.id and role == END_USER.""" + module = sys.modules["controllers.openapi.workflow_events"] + run = _make_workflow_run(created_by_role=CreatorUserRole.END_USER, created_by="eu-1") + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + msg_gen_mock = Mock() + msg_gen_mock.retrieve_events.return_value = iter([]) + monkeypatch.setattr(module, "MessageGenerator", lambda: msg_gen_mock) + + generator_mock = Mock() + generator_mock.convert_to_event_stream.return_value = iter([]) + monkeypatch.setattr(module, "WorkflowAppGenerator", lambda: generator_mock) + + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + api = self._get_api() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + resp = api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="eu-1"), + caller_kind="end_user", + ) + assert resp.mimetype == "text/event-stream" + + def test_finished_run_returns_single_sse_event(self, app, bypass_pipeline, monkeypatch): + """A finished run returns a single done-event SSE response without streaming.""" + from datetime import UTC, datetime + + module = sys.modules["controllers.openapi.workflow_events"] + finished_at = datetime(2024, 1, 1, tzinfo=UTC) + run = _make_workflow_run( + created_by_role=CreatorUserRole.ACCOUNT, + created_by="acct-1", + finished_at=finished_at, + ) + repo_mock = Mock() + repo_mock.get_workflow_run_by_id_and_tenant_id.return_value = run + factory_mock = Mock() + factory_mock.create_api_workflow_run_repository.return_value = repo_mock + monkeypatch.setattr(module, "DifyAPIRepositoryFactory", factory_mock) + monkeypatch.setattr(module, "sessionmaker", Mock(return_value=object())) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + + finish_response = SimpleNamespace( + event=SimpleNamespace(value="workflow_finished"), + model_dump=lambda mode=None: {"task_id": "wf-run-1", "status": "succeeded"}, + ) + converter_mock = Mock() + converter_mock.workflow_run_result_to_finish_response.return_value = finish_response + monkeypatch.setattr(module, "WorkflowResponseConverter", converter_mock) + + from models.model import AppMode + + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) + + api = self._get_api() + with app.test_request_context("/openapi/v1/apps/app-1/tasks/wf-run-1/events"): + resp = api.get.__wrapped__( + api, + app_id="app-1", + task_id="wf-run-1", + app_model=app_model, + caller=SimpleNamespace(id="acct-1"), + caller_kind="account", + ) + assert resp.mimetype == "text/event-stream" + chunks = list(resp.response) + data = b"".join(c if isinstance(c, bytes) else c.encode() for c in chunks).decode() + assert "workflow_finished" in data diff --git a/api/tests/unit_tests/controllers/openapi/test_workspaces.py b/api/tests/unit_tests/controllers/openapi/test_workspaces.py new file mode 100644 index 0000000000..9cdc13a395 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_workspaces.py @@ -0,0 +1,58 @@ +"""Phase E step 17: workspace reads at /openapi/v1/workspaces. Bearer-authed +list + member-gated detail. No legacy /v1/ equivalent — the cookie-authed +/console/api/workspaces is a separate consumer that stays in console. +""" + +import builtins + +import pytest +from flask import Flask +from flask.views import MethodView + +from controllers.openapi import bp as openapi_bp +from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspacesApi + +if not hasattr(builtins, "MethodView"): + builtins.MethodView = MethodView # type: ignore[attr-defined] + + +@pytest.fixture +def openapi_app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(openapi_bp) + return app + + +def _rule(app: Flask, path: str): + return next(r for r in app.url_map.iter_rules() if r.rule == path) + + +def test_workspaces_list_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/workspaces" in rules + + +def test_workspaces_list_dispatches_to_workspaces_api(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/workspaces") + assert openapi_app.view_functions[rule.endpoint].view_class is WorkspacesApi + assert "GET" in rule.methods + + +def test_workspace_by_id_route_registered(openapi_app: Flask): + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/openapi/v1/workspaces/" in rules + + +def test_workspace_by_id_dispatches_to_correct_class(openapi_app: Flask): + rule = _rule(openapi_app, "/openapi/v1/workspaces/") + assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceByIdApi + assert "GET" in rule.methods + + +def test_console_legacy_workspaces_route_not_remounted_on_openapi(openapi_app: Flask): + """Phase E only adds the bearer-authed mounts on /openapi/v1/. + The cookie-authed /console/api/workspaces stays where it is. + """ + rules = {r.rule for r in openapi_app.url_map.iter_rules()} + assert "/console/api/workspaces" not in rules diff --git a/api/tests/unit_tests/core/app/test_invoke_from.py b/api/tests/unit_tests/core/app/test_invoke_from.py new file mode 100644 index 0000000000..e0a8344d2f --- /dev/null +++ b/api/tests/unit_tests/core/app/test_invoke_from.py @@ -0,0 +1,9 @@ +from core.app.entities.app_invoke_entities import InvokeFrom + + +def test_openapi_variant_present(): + assert InvokeFrom.OPENAPI.value == "openapi" + + +def test_openapi_distinct_from_service_api(): + assert InvokeFrom.OPENAPI != InvokeFrom.SERVICE_API diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py b/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py new file mode 100644 index 0000000000..b78e821237 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy_openapi.py @@ -0,0 +1,34 @@ +"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms.""" + +from __future__ import annotations + +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from models.human_input import RecipientType + + +def test_openapi_surface_exists(): + assert HumanInputSurface.OPENAPI == "openapi" + + +def test_openapi_allows_standalone_web_app(): + assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI) + + +def test_openapi_rejects_console_recipient(): + assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI) + + +def test_openapi_rejects_backstage_recipient(): + assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI) + + +def test_get_surface_form_token_openapi_picks_standalone_web_app(): + """OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API.""" + from core.workflow.human_input_forms import _get_surface_form_token + + recipients = [ + (RecipientType.BACKSTAGE, "backstage-token"), + (RecipientType.STANDALONE_WEB_APP, "web-token"), + ] + token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI) + assert token == "web-token" diff --git a/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py new file mode 100644 index 0000000000..602cd8f019 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_ext_blueprints_openapi.py @@ -0,0 +1,124 @@ +"""Verifies the OPENAPI_ENABLED gate in `extensions.ext_blueprints.init_app`. + +Contract: +- When `dify_config.OPENAPI_ENABLED` is True, the `/openapi/v1/*` blueprint + must be registered on the Flask app AND have CORS configured (signalled + by the idempotent `_dify_cors_applied` marker that `_apply_cors_once` + sets after wiring `flask_cors.CORS`). +- When False, the openapi blueprint must NOT be registered and CORS must + NOT be wired for it. The cross-origin posture is otherwise undefined for + the disabled feature. + +Why the blueprints are swapped for fresh ones: +`init_app` operates on module-level blueprint singletons (e.g. +`controllers.service_api.bp`). Other unit tests register those same +singletons onto throwaway Flask apps without going through `init_app` +(see `tests/unit_tests/controllers/test_swagger.py` and the +`tests/unit_tests/controllers/openapi/*` suite). Once a blueprint has +been registered to any app, Flask flips `_got_registered_once = True` +and rejects further `after_request` hookup -- which is what +`flask_cors.CORS(bp, ...)` does internally. To make this gate test +order-independent we monkeypatch each consumed `controllers..bp` to +a pristine, never-registered `Blueprint` for the duration of the test. +`init_app` resolves these via `from controllers. import bp as ...` +inside its function body, so the patched value is what it sees. +""" + +from __future__ import annotations + +import importlib +from collections.abc import Iterator + +import pytest +from flask import Blueprint + +from configs import dify_config +from dify_app import DifyApp +from extensions import ext_blueprints + +# Modules whose `bp` attribute is consumed by `ext_blueprints.init_app`. +# Keep in sync with the imports inside `init_app`. +_BLUEPRINT_MODULES: tuple[str, ...] = ( + "controllers.console", + "controllers.files", + "controllers.inner_api", + "controllers.mcp", + "controllers.openapi", + "controllers.service_api", + "controllers.trigger", + "controllers.web", +) + + +def _probe_view() -> tuple[str, int]: + return "ok", 200 + + +@pytest.fixture +def fresh_blueprints(monkeypatch: pytest.MonkeyPatch) -> Iterator[dict[str, Blueprint]]: + """Replace each production blueprint singleton with a fresh, unregistered copy. + + Mirrors the production `name` and `url_prefix` so the gate can still + be asserted via `app.blueprints[...]` and url_map prefix checks. The + openapi replacement gets a dummy `/_probe` rule so the test can + observe at least one `/openapi/v1/*` rule after registration. + """ + fresh: dict[str, Blueprint] = {} + for module_name in _BLUEPRINT_MODULES: + module = importlib.import_module(module_name) + original = module.bp + replacement = Blueprint( + original.name, + original.import_name, + url_prefix=original.url_prefix, + ) + if module_name == "controllers.openapi": + replacement.add_url_rule("/_probe", endpoint="_probe", view_func=_probe_view) + monkeypatch.setattr(module, "bp", replacement) + fresh[module_name] = replacement + return fresh + + +def _build_app() -> DifyApp: + app = DifyApp(__name__) + app.config["TESTING"] = True + return app + + +def test_openapi_blueprint_registered_with_cors_when_enabled( + monkeypatch: pytest.MonkeyPatch, + fresh_blueprints: dict[str, Blueprint], +) -> None: + """Enabled gate: blueprint mounted, CORS wired, `/openapi/v1/*` rules live.""" + monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", True) + + app = _build_app() + ext_blueprints.init_app(app) + + openapi_bp = fresh_blueprints["controllers.openapi"] + assert "openapi" in app.blueprints + assert app.blueprints["openapi"] is openapi_bp + # `_apply_cors_once` only sets this after wiring flask_cors.CORS, so + # it is a faithful proxy for "CORS was applied to this blueprint". + assert getattr(openapi_bp, "_dify_cors_applied", False) is True + + openapi_rules = [r for r in app.url_map.iter_rules() if r.rule.startswith("/openapi/v1")] + assert openapi_rules, "expected at least one /openapi/v1/* route once enabled" + + +def test_openapi_blueprint_absent_when_disabled( + monkeypatch: pytest.MonkeyPatch, + fresh_blueprints: dict[str, Blueprint], +) -> None: + """Disabled gate: no blueprint, no CORS, no `/openapi/v1/*` URL rules.""" + monkeypatch.setattr(dify_config, "OPENAPI_ENABLED", False) + + app = _build_app() + ext_blueprints.init_app(app) + + openapi_bp = fresh_blueprints["controllers.openapi"] + assert "openapi" not in app.blueprints + # No CORS wiring should have run for the openapi blueprint. + assert not hasattr(openapi_bp, "_dify_cors_applied") + openapi_rules = [r for r in app.url_map.iter_rules() if r.rule.startswith("/openapi/v1")] + assert openapi_rules == [] diff --git a/api/tests/unit_tests/libs/test_oauth_bearer.py b/api/tests/unit_tests/libs/test_oauth_bearer.py new file mode 100644 index 0000000000..1ce25a48f7 --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_bearer.py @@ -0,0 +1,29 @@ +"""Unit tests for the openapi bearer-scope catalog and TokenKind registry.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + + +def test_apps_read_permitted_external_scope_present(): + from libs.oauth_bearer import Scope + + assert Scope.APPS_READ_PERMITTED_EXTERNAL.value == "apps:read:permitted-external" + + +def test_dfoe_token_kind_carries_apps_read_permitted_external(): + from libs.oauth_bearer import Scope, build_registry + + registry = build_registry(MagicMock(), MagicMock()) + dfoe = next(k for k in registry.kinds() if k.prefix == "dfoe_") + assert Scope.APPS_READ_PERMITTED_EXTERNAL in dfoe.scopes + + +def test_dfoa_token_kind_does_not_carry_apps_read_permitted_external(): + """dfoa_ relies on Scope.FULL umbrella; the explicit permitted scope + is reserved for dfoe_.""" + from libs.oauth_bearer import Scope, build_registry + + registry = build_registry(MagicMock(), MagicMock()) + dfoa = next(k for k in registry.kinds() if k.prefix == "dfoa_") + assert Scope.APPS_READ_PERMITTED_EXTERNAL not in dfoa.scopes diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py b/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py new file mode 100644 index 0000000000..0023f17119 --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_bearer_layer0_cache.py @@ -0,0 +1,94 @@ +"""Unit tests for record_layer0_verdict — merge L0 verdict into AuthContext cache.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from libs.oauth_bearer import record_layer0_verdict + + +@pytest.fixture +def mock_redis(): + return MagicMock() + + +@patch("libs.oauth_bearer.redis_client") +def test_no_op_when_cache_entry_missing(mock_redis): + mock_redis.get.return_value = None + record_layer0_verdict("h1", "t1", True) + mock_redis.setex.assert_not_called() + + +@patch("libs.oauth_bearer.redis_client") +def test_no_op_when_cache_entry_invalid_marker(mock_redis): + mock_redis.get.return_value = b"invalid" + record_layer0_verdict("h1", "t1", True) + mock_redis.setex.assert_not_called() + + +@patch("libs.oauth_bearer.redis_client") +def test_no_op_when_json_malformed(mock_redis): + mock_redis.get.return_value = b"not json" + record_layer0_verdict("h1", "t1", True) + mock_redis.setex.assert_not_called() + + +@patch("libs.oauth_bearer.redis_client") +def test_no_op_when_ttl_expired(mock_redis): + mock_redis.get.return_value = json.dumps( + { + "subject_email": "e", + "subject_issuer": None, + "account_id": None, + "token_id": "tid", + "expires_at": None, + } + ).encode() + mock_redis.ttl.return_value = -1 + record_layer0_verdict("h1", "t1", True) + mock_redis.setex.assert_not_called() + + +@patch("libs.oauth_bearer.redis_client") +def test_merges_new_tenant_verdict(mock_redis): + mock_redis.get.return_value = json.dumps( + { + "subject_email": "e", + "subject_issuer": None, + "account_id": None, + "token_id": "tid", + "expires_at": None, + "verified_tenants": {"t0": True}, + } + ).encode() + mock_redis.ttl.return_value = 42 + + record_layer0_verdict("h1", "t1", False) + + mock_redis.setex.assert_called_once() + args = mock_redis.setex.call_args + assert args.args[0] == "auth:token:h1" + assert args.args[1] == 42 # remaining TTL preserved + written = json.loads(args.args[2]) + assert written["verified_tenants"] == {"t0": True, "t1": False} + + +@patch("libs.oauth_bearer.redis_client") +def test_merges_when_field_absent_from_legacy_entry(mock_redis): + """Backward compat: legacy cache entry without verified_tenants field.""" + mock_redis.get.return_value = json.dumps( + { + "subject_email": "e", + "subject_issuer": None, + "account_id": None, + "token_id": "tid", + "expires_at": None, + } + ).encode() + mock_redis.ttl.return_value = 42 + record_layer0_verdict("h1", "t1", True) + written = json.loads(mock_redis.setex.call_args.args[2]) + assert written["verified_tenants"] == {"t1": True} diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py new file mode 100644 index 0000000000..dd4304ccb1 --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_bearer_rate_limit_ordering.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from libs.oauth_bearer import ( + BearerAuthenticator, + InvalidBearerError, + Scope, + SubjectType, + TokenKind, + TokenKindRegistry, +) + + +def _registry_with_resolver(resolver) -> TokenKindRegistry: + return TokenKindRegistry( + [ + TokenKind( + prefix="dfoa_", + subject_type=SubjectType.ACCOUNT, + scopes=frozenset({Scope.FULL}), + source="oauth_account", + resolver=resolver, + ) + ] + ) + + +@patch("libs.oauth_bearer.enforce_bearer_rate_limit") +def test_rate_limit_called_on_unknown_revoked_token(rl): + resolver = MagicMock() + resolver.resolve.return_value = None + auth = BearerAuthenticator(_registry_with_resolver(resolver)) + + with pytest.raises(InvalidBearerError): + auth.authenticate("dfoa_revokedtoken123") + + rl.assert_called_once() + resolver.resolve.assert_called_once() + + +@patch("libs.oauth_bearer.enforce_bearer_rate_limit") +def test_rate_limit_called_before_resolve(rl): + call_order: list[str] = [] + rl.side_effect = lambda _h: call_order.append("rl") + resolver = MagicMock() + resolver.resolve.side_effect = lambda _h: call_order.append("resolve") or None + auth = BearerAuthenticator(_registry_with_resolver(resolver)) + + with pytest.raises(InvalidBearerError): + auth.authenticate("dfoa_xyz") + + assert call_order == ["rl", "resolve"], f"expected rl before resolve, got {call_order}" + + +def test_unknown_prefix_raises_generic_invalid_bearer(): + auth = BearerAuthenticator( + TokenKindRegistry( + [ + TokenKind( + prefix="dfoa_", + subject_type=SubjectType.ACCOUNT, + scopes=frozenset({Scope.FULL}), + source="oauth_account", + resolver=MagicMock(), + ) + ] + ) + ) + with pytest.raises(InvalidBearerError) as exc: + auth.authenticate("zzz_xyz") + assert str(exc.value) == "invalid_bearer" + + +@patch("libs.oauth_bearer.enforce_bearer_rate_limit") +def test_revoked_token_raises_generic_invalid_bearer(rl): + resolver = MagicMock() + resolver.resolve.return_value = None + auth = BearerAuthenticator(_registry_with_resolver(resolver)) + with pytest.raises(InvalidBearerError) as exc: + auth.authenticate("dfoa_revoked") + assert str(exc.value) == "invalid_bearer" diff --git a/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py b/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py new file mode 100644 index 0000000000..898e4578e6 --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_bearer_require_scope.py @@ -0,0 +1,97 @@ +"""require_scope is a route-level gate run after validate_bearer. +Tests use a fake auth_ctx published via the openapi auth ContextVar (no +authenticator wiring needed). The `_publish_auth_ctx` helper guarantees +the ContextVar is reset between tests so worker-thread reuse can't leak +identity into the next test. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from contextlib import contextmanager + +import pytest +from flask import Flask +from werkzeug.exceptions import Forbidden + +from libs.oauth_bearer import ( + AuthContext, + Scope, + SubjectType, + require_scope, + reset_auth_ctx, + set_auth_ctx, +) + + +@contextmanager +def _publish_auth_ctx(ctx: AuthContext) -> Iterator[None]: + token = set_auth_ctx(ctx) + try: + yield + finally: + reset_auth_ctx(token) + + +@pytest.fixture +def app() -> Flask: + app = Flask(__name__) + app.config["TESTING"] = True + return app + + +def _ctx(scopes) -> AuthContext: + return AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email="user@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + scopes=scopes, + token_id=uuid.uuid4(), + source="oauth_account", + expires_at=None, + token_hash="h1", + verified_tenants={}, + ) + + +def test_require_scope_allows_when_scope_present(app: Flask): + @require_scope("apps:read") + def view(): + return "ok" + + with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({"apps:read"}))): + assert view() == "ok" + + +def test_require_scope_rejects_when_scope_missing(app: Flask): + @require_scope("apps:write") + def view(): + return "ok" + + with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({"apps:read"}))): + with pytest.raises(Forbidden) as exc: + view() + assert "insufficient_scope: apps:write" in str(exc.value.description) + + +def test_require_scope_full_passes_any_check(app: Flask): + @require_scope("apps:write") + def view(): + return "ok" + + with app.test_request_context(), _publish_auth_ctx(_ctx(frozenset({Scope.FULL}))): + assert view() == "ok" + + +def test_require_scope_without_validate_bearer_raises_runtime_error(app: Flask): + @require_scope("apps:read") + def view(): + return "ok" + + with app.test_request_context(): + # No auth ContextVar published — validate_bearer was forgotten. + with pytest.raises(RuntimeError, match="stack @validate_bearer above @require_scope"): + view() diff --git a/api/tests/unit_tests/libs/test_rate_limit_bearer.py b/api/tests/unit_tests/libs/test_rate_limit_bearer.py new file mode 100644 index 0000000000..b204575ccb --- /dev/null +++ b/api/tests/unit_tests/libs/test_rate_limit_bearer.py @@ -0,0 +1,74 @@ +"""Unit tests for the per-token bearer rate limit primitive.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import TooManyRequests + +from libs.helper import RateLimiter +from libs.rate_limit import ( + LIMIT_BEARER_PER_TOKEN, + enforce_bearer_rate_limit, +) + + +@pytest.fixture +def mock_redis(): + return MagicMock() + + +def test_limit_bearer_per_token_uses_60_per_minute_default(): + assert LIMIT_BEARER_PER_TOKEN.limit == 60 + assert LIMIT_BEARER_PER_TOKEN.window == timedelta(minutes=1) + + +def test_seconds_until_available_returns_remaining_window(mock_redis): + """ZSET oldest entry score = 100; window = 60s; now = 130s → remaining = 30s.""" + rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis) + mock_redis.zrange.return_value = [(b"member-1", 100.0)] + with patch("libs.helper.time.time", return_value=130): + assert rl.seconds_until_available("k1") == 30 + + +def test_seconds_until_available_floor_one_second(mock_redis): + """Even when math says <1s remaining, return at least 1 so client backs off measurably.""" + rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis) + mock_redis.zrange.return_value = [(b"member-1", 119.5)] + with patch("libs.helper.time.time", return_value=180): + # window expired (180 > 119.5+60=179.5 by 0.5s) — bucket is actually free now + # but this method only called when is_rate_limited() == True; defensive floor. + assert rl.seconds_until_available("k1") >= 1 + + +def test_seconds_until_available_empty_bucket(mock_redis): + """No entries → 1s sentinel (defensive; should not be reached when limited).""" + rl = RateLimiter("rl:bearer:token", max_attempts=60, time_window=60, redis_client=mock_redis) + mock_redis.zrange.return_value = [] + assert rl.seconds_until_available("k1") == 1 + + +@patch("libs.rate_limit._build_limiter") +def test_enforce_bearer_rate_limit_passes_under_limit(mock_build): + limiter = MagicMock() + limiter.is_rate_limited.return_value = False + mock_build.return_value = limiter + enforce_bearer_rate_limit("hash-1") + limiter.increment_rate_limit.assert_called_once_with("token:hash-1") + + +@patch("libs.rate_limit._build_limiter") +def test_enforce_bearer_rate_limit_raises_429_with_retry_after(mock_build): + limiter = MagicMock() + limiter.is_rate_limited.return_value = True + limiter.seconds_until_available.return_value = 23 + mock_build.return_value = limiter + with pytest.raises(TooManyRequests) as exc: + enforce_bearer_rate_limit("hash-1") + headers = dict(exc.value.get_response().headers) + assert headers.get("Retry-After") == "23" + body = exc.value.get_response().get_json() or {} + assert body.get("error") == "rate_limited" + assert body.get("retry_after_ms") == 23000 diff --git a/api/tests/unit_tests/libs/test_workspace_member_helper.py b/api/tests/unit_tests/libs/test_workspace_member_helper.py new file mode 100644 index 0000000000..540e19ad9e --- /dev/null +++ b/api/tests/unit_tests/libs/test_workspace_member_helper.py @@ -0,0 +1,94 @@ +"""Unit tests for require_workspace_member.""" + +from __future__ import annotations + +import uuid +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import Forbidden + +from libs.oauth_bearer import AuthContext, Scope, SubjectType, require_workspace_member + + +def _ctx(verified: dict[str, bool] | None = None, *, account: bool = True) -> AuthContext: + return AuthContext( + subject_type=SubjectType.ACCOUNT if account else SubjectType.EXTERNAL_SSO, + subject_email="e@example.com", + subject_issuer=None, + account_id=uuid.uuid4() if account else None, + client_id="difyctl", + scopes=frozenset({Scope.FULL}), + token_id=uuid.uuid4(), + source="oauth_account", + expires_at=None, + token_hash="h1", + verified_tenants=dict(verified or {}), + ) + + +@patch("libs.oauth_bearer.dify_config") +def test_skips_when_enterprise_enabled(mock_cfg): + mock_cfg.ENTERPRISE_ENABLED = True + require_workspace_member(_ctx(), "t1") + + +@patch("libs.oauth_bearer.dify_config") +def test_skips_for_external_sso(mock_cfg): + mock_cfg.ENTERPRISE_ENABLED = False + require_workspace_member(_ctx(account=False), "t1") + + +@patch("libs.oauth_bearer.db") +@patch("libs.oauth_bearer.dify_config") +def test_uses_cached_ok_no_db_access(mock_cfg, mock_db): + mock_cfg.ENTERPRISE_ENABLED = False + require_workspace_member(_ctx({"t1": True}), "t1") + mock_db.session.execute.assert_not_called() + + +@patch("libs.oauth_bearer.db") +@patch("libs.oauth_bearer.dify_config") +def test_uses_cached_denied(mock_cfg, mock_db): + mock_cfg.ENTERPRISE_ENABLED = False + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + require_workspace_member(_ctx({"t1": False}), "t1") + mock_db.session.execute.assert_not_called() + + +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +@patch("libs.oauth_bearer.dify_config") +def test_denies_when_no_membership(mock_cfg, mock_db, mock_record): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.return_value.scalar_one_or_none.return_value = None + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + require_workspace_member(_ctx({}), "t1") + mock_record.assert_called_once_with("h1", "t1", False) + + +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +@patch("libs.oauth_bearer.dify_config") +def test_denies_when_account_inactive(mock_cfg, mock_db, mock_record): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.side_effect = [ + MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")), + MagicMock(scalar_one_or_none=MagicMock(return_value="banned")), + ] + with pytest.raises(Forbidden, match="workspace_membership_revoked"): + require_workspace_member(_ctx({}), "t1") + mock_record.assert_called_once_with("h1", "t1", False) + + +@patch("libs.oauth_bearer.record_layer0_verdict") +@patch("libs.oauth_bearer.db") +@patch("libs.oauth_bearer.dify_config") +def test_allows_active_member(mock_cfg, mock_db, mock_record): + mock_cfg.ENTERPRISE_ENABLED = False + mock_db.session.execute.side_effect = [ + MagicMock(scalar_one_or_none=MagicMock(return_value="join-id")), + MagicMock(scalar_one_or_none=MagicMock(return_value="active")), + ] + require_workspace_member(_ctx({}), "t1") + mock_record.assert_called_once_with("h1", "t1", True) diff --git a/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py b/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py new file mode 100644 index 0000000000..339f783ca8 --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +import pytest + +from services.enterprise.app_permitted_service import PermittedAppsPage, list_permitted_apps +from services.errors.enterprise import EnterpriseAPIError + +WRAPPER = "services.enterprise.app_permitted_service.EnterpriseService.WebAppAuth.list_externally_accessible_apps" + + +def test_list_permitted_apps_decodes_camelcase_response(): + fake_body = { + "data": [{"appId": "a"}, {"appId": "b"}], + "total": 2, + "hasMore": False, + } + with patch(WRAPPER, return_value=fake_body) as m: + page = list_permitted_apps(page=1, limit=10) + + assert isinstance(page, PermittedAppsPage) + assert page.total == 2 + assert page.has_more is False + assert page.app_ids == ["a", "b"] + m.assert_called_once_with(page=1, limit=10, mode=None, name=None) + + +def test_list_permitted_apps_passes_filters_to_wrapper(): + fake_body = {"data": [], "total": 0, "hasMore": False} + with patch(WRAPPER, return_value=fake_body) as m: + list_permitted_apps(page=2, limit=5, mode="workflow", name="alpha") + + m.assert_called_once_with(page=2, limit=5, mode="workflow", name="alpha") + + +def test_list_permitted_apps_503_on_ee_error(): + with patch(WRAPPER, side_effect=EnterpriseAPIError("boom", status_code=500)): + from werkzeug.exceptions import ServiceUnavailable + + with pytest.raises(ServiceUnavailable): + list_permitted_apps(page=1, limit=10) + + +def test_list_permitted_apps_503_on_status_error(): + with patch(WRAPPER, side_effect=EnterpriseAPIError("bad key", status_code=401)): + from werkzeug.exceptions import ServiceUnavailable + + with pytest.raises(ServiceUnavailable): + list_permitted_apps(page=1, limit=10) + + +def test_list_permitted_apps_handles_empty_response(): + fake_body = {"data": [], "total": 0, "hasMore": False} + with patch(WRAPPER, return_value=fake_body): + page = list_permitted_apps(page=1, limit=10) + assert page.app_ids == [] + assert page.total == 0 + assert page.has_more is False diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py index 6ad6a490b0..599a9a7b95 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -188,6 +188,31 @@ class TestWebAppAuth: req.send_request.assert_called_once_with("DELETE", "/webapp/clean", params={"appId": "a1"}) + def test_list_externally_accessible_apps_minimal_call(self): + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"data": [], "total": 0, "hasMore": False} + result = EnterpriseService.WebAppAuth.list_externally_accessible_apps(page=1, limit=10) + + assert result == {"data": [], "total": 0, "hasMore": False} + req.send_request.assert_called_once_with( + "POST", + "/webapp/externally-accessible-apps", + json={"page": 1, "limit": 10}, + timeout=5.0, + ) + + def test_list_externally_accessible_apps_with_filters(self): + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"data": [], "total": 0, "hasMore": False} + EnterpriseService.WebAppAuth.list_externally_accessible_apps(page=2, limit=5, mode="workflow", name="alpha") + + req.send_request.assert_called_once_with( + "POST", + "/webapp/externally-accessible-apps", + json={"page": 2, "limit": 5, "mode": "workflow", "name": "alpha"}, + timeout=5.0, + ) + class TestJoinDefaultWorkspace: def test_join_default_workspace_success(self): diff --git a/api/tests/unit_tests/services/openapi/__init__.py b/api/tests/unit_tests/services/openapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/services/openapi/test_mint_policy.py b/api/tests/unit_tests/services/openapi/test_mint_policy.py new file mode 100644 index 0000000000..7409a064a9 --- /dev/null +++ b/api/tests/unit_tests/services/openapi/test_mint_policy.py @@ -0,0 +1,126 @@ +"""Tests for the mint-policy validator. + +Cross-checks the (subject_type, prefix, scopes) triple a caller intends +to mint against ``MINTABLE_PROFILES``. The validator's defense-in-depth +value kicks in when a caller wires scopes or prefix from a non-canonical +source — the well-formed canonical path is the no-violation case. +""" + +from __future__ import annotations + +import pytest + +from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType +from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy + + +def test_canonical_account_profile_passes(): + profile = MINTABLE_PROFILES[SubjectType.ACCOUNT] + validate_mint_policy( + subject_type=profile.subject_type, + prefix=profile.prefix, + scopes=profile.scopes, + ) + + +def test_canonical_external_sso_profile_passes(): + profile = MINTABLE_PROFILES[SubjectType.EXTERNAL_SSO] + validate_mint_policy( + subject_type=profile.subject_type, + prefix=profile.prefix, + scopes=profile.scopes, + ) + + +def test_wrong_prefix_rejected(): + with pytest.raises(MintPolicyViolation) as exc: + validate_mint_policy( + subject_type=SubjectType.ACCOUNT, + prefix="dfoe_", # SSO prefix on an account subject + scopes=frozenset({Scope.FULL}), + ) + assert "prefix" in str(exc.value) + + +def test_wrong_scopes_rejected(): + with pytest.raises(MintPolicyViolation) as exc: + validate_mint_policy( + subject_type=SubjectType.ACCOUNT, + prefix="dfoa_", + scopes=frozenset({Scope.APPS_RUN}), # account should be {FULL} + ) + assert "scopes" in str(exc.value) + + +def test_external_sso_with_full_scope_rejected(): + with pytest.raises(MintPolicyViolation): + validate_mint_policy( + subject_type=SubjectType.EXTERNAL_SSO, + prefix="dfoe_", + scopes=frozenset({Scope.FULL}), # FULL never applies to dfoe_ + ) + + +def test_message_carries_both_drift_reasons(): + """Mismatched prefix AND mismatched scopes both surface in one error.""" + with pytest.raises(MintPolicyViolation) as exc: + validate_mint_policy( + subject_type=SubjectType.ACCOUNT, + prefix="dfoe_", + scopes=frozenset({Scope.APPS_RUN}), + ) + msg = str(exc.value) + assert "prefix" in msg + assert "scopes" in msg + + +def test_license_required_decorator_skips_on_ce(): + from unittest.mock import patch + + from services.openapi.license_gate import license_required + + @license_required + def view(): + return "ok" + + with patch("services.openapi.license_gate.dify_config") as cfg: + cfg.ENTERPRISE_ENABLED = False + assert view() == "ok" + + +def test_license_required_decorator_403_on_invalid_ee_license(): + from unittest.mock import patch + + from werkzeug.exceptions import Forbidden + + from services.openapi.license_gate import license_required + + @license_required + def view(): + return "ok" + + with ( + patch("services.openapi.license_gate.dify_config") as cfg, + patch("services.openapi.license_gate._is_license_valid", return_value=False), + ): + cfg.ENTERPRISE_ENABLED = True + with pytest.raises(Forbidden) as exc: + view() + assert "license_required" in exc.value.description + + +def test_license_required_decorator_passes_on_valid_ee_license(): + from unittest.mock import patch + + from services.openapi.license_gate import license_required + + @license_required + def view(): + return "ok" + + with ( + patch("services.openapi.license_gate.dify_config") as cfg, + patch("services.openapi.license_gate._is_license_valid", return_value=True), + ): + cfg.ENTERPRISE_ENABLED = True + assert view() == "ok" diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 8c554e012d..5e89d9fb42 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -1989,3 +1989,162 @@ class TestRegisterService: # Verify results assert result is None + + +class TestSessionInjectedGetters: + """Coverage for the session-injected getters used by the openapi + surface. These methods bypass the Flask-scoped ``db.session`` + proxy: callers (controllers) pass a session in. The tests simply + verify the delegation contract — that the method routes the call + through the *passed* session, not through ``db.session``. + """ + + def test_get_account_by_id_uses_passed_session_no_side_effects(self): + """``get_account_by_id`` must be a plain delegation to + ``session.get(Account, ...)`` — no banned-status raise, no + commit (those are the side-effects of ``load_user`` we + explicitly want to skip). + """ + mock_session = MagicMock() + sentinel_account = MagicMock(spec=Account) + mock_session.get.return_value = sentinel_account + + result = AccountService.get_account_by_id(mock_session, "user-123") + + assert result is sentinel_account + mock_session.get.assert_called_once_with(Account, "user-123") + mock_session.commit.assert_not_called() + + def test_get_account_by_id_returns_none_for_unknown_account(self): + mock_session = MagicMock() + mock_session.get.return_value = None + + assert AccountService.get_account_by_id(mock_session, "missing") is None + + def test_get_account_by_email_returns_scalar_or_none(self): + """Plain getter — case-sensitive equality (callers needing the + case-insensitive existence check use + :meth:`has_active_account_with_email`). + """ + mock_session = MagicMock() + sentinel = MagicMock(spec=Account) + mock_session.execute.return_value.scalar_one_or_none.return_value = sentinel + + assert AccountService.get_account_by_email(mock_session, "alice@example.com") is sentinel + + mock_session.execute.return_value.scalar_one_or_none.return_value = None + assert AccountService.get_account_by_email(mock_session, "ghost@example.com") is None + + def test_account_belongs_to_tenant_short_circuits_on_falsy_account_id(self): + """SSO bearers with no ``account_id`` (and any other falsy id) + must collapse to ``False`` without a DB round-trip — that's the + contract :class:`MembershipStrategy` relies on. + """ + mock_session = MagicMock() + + assert TenantService.account_belongs_to_tenant(mock_session, None, "tenant-1") is False + assert TenantService.account_belongs_to_tenant(mock_session, "", "tenant-1") is False + mock_session.execute.assert_not_called() + + def test_account_belongs_to_tenant_true_when_join_row_exists(self): + mock_session = MagicMock() + mock_session.execute.return_value.scalar_one_or_none.return_value = "join-id" + + assert TenantService.account_belongs_to_tenant(mock_session, "user-1", "tenant-1") is True + mock_session.execute.assert_called_once() + + def test_account_belongs_to_tenant_false_when_no_join(self): + mock_session = MagicMock() + mock_session.execute.return_value.scalar_one_or_none.return_value = None + + assert TenantService.account_belongs_to_tenant(mock_session, "user-1", "tenant-1") is False + + def test_get_account_memberships_returns_join_tenant_pairs(self): + """Returns whatever ``session.query(...).join(...).filter(...).all()`` + produces — ordering unspecified, callers pick the default + workspace from the join row. + """ + mock_session = MagicMock() + rows = [(MagicMock(), MagicMock()), (MagicMock(), MagicMock())] + mock_session.query.return_value.join.return_value.filter.return_value.all.return_value = rows + + out = TenantService.get_account_memberships(mock_session, "user-123") + + assert out == rows + # No fall-through to the global db.session proxy. + assert mock_session.query.called + + def test_get_workspaces_for_account_uses_session_execute(self): + """The list endpoint orders by ``Tenant.created_at``; the helper + passes the ordered query through ``session.execute(...).all()``. + """ + mock_session = MagicMock() + rows = [(MagicMock(), MagicMock())] + mock_session.execute.return_value.all.return_value = rows + + out = TenantService.get_workspaces_for_account(mock_session, "user-123") + + assert out == rows + assert mock_session.execute.called + + def test_get_tenant_by_id_is_plain_session_get(self): + """``get_tenant_by_id`` must NOT apply a status filter — the + openapi auth pipeline needs to map ``status == ARCHIVE`` to a + 403, distinct from a 404 for "missing". + """ + from models import Tenant + + mock_session = MagicMock() + sentinel = MagicMock(spec=Tenant) + mock_session.get.return_value = sentinel + + assert TenantService.get_tenant_by_id(mock_session, "tenant-1") is sentinel + mock_session.get.assert_called_once_with(Tenant, "tenant-1") + + def test_get_tenant_by_id_returns_none_when_missing(self): + mock_session = MagicMock() + mock_session.get.return_value = None + + assert TenantService.get_tenant_by_id(mock_session, "missing") is None + + def test_get_tenants_by_ids_short_circuits_on_empty_input(self): + """Empty id list must not emit ``WHERE id IN ()``.""" + mock_session = MagicMock() + + assert TenantService.get_tenants_by_ids(mock_session, []) == [] + mock_session.execute.assert_not_called() + + def test_get_tenants_by_ids_returns_scalars(self): + mock_session = MagicMock() + tenants = [MagicMock(), MagicMock()] + mock_session.execute.return_value.scalars.return_value.all.return_value = tenants + + assert TenantService.get_tenants_by_ids(mock_session, ["t1", "t2"]) == tenants + mock_session.execute.assert_called_once() + + def test_get_tenant_name_returns_scalar_or_none(self): + """Single-column lookup: ``session.execute(...).scalar_one_or_none()`` + — used by openapi list endpoints to denormalise + ``workspace_name`` onto each row. + """ + mock_session = MagicMock() + mock_session.execute.return_value.scalar_one_or_none.return_value = "Acme Inc." + + assert TenantService.get_tenant_name(mock_session, "tenant-1") == "Acme Inc." + + mock_session.execute.return_value.scalar_one_or_none.return_value = None + assert TenantService.get_tenant_name(mock_session, "missing") is None + + def test_find_workspace_for_account_returns_first_row_or_none(self): + """Per-id read returns ``session.execute(...).first()`` directly; + callers map ``None`` → 404 to avoid leaking workspace IDs across + tenants. + """ + mock_session = MagicMock() + sentinel_row = (MagicMock(), MagicMock()) + mock_session.execute.return_value.first.return_value = sentinel_row + + assert TenantService.find_workspace_for_account(mock_session, "user-123", "ws-1") is sentinel_row + + mock_session.execute.return_value.first.return_value = None + assert TenantService.find_workspace_for_account(mock_session, "user-123", "ws-1") is None diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py new file mode 100644 index 0000000000..610b32ac3c --- /dev/null +++ b/api/tests/unit_tests/services/test_app_service.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from models.model import App +from services.app_service import AppService + + +class TestOpenapiVisibilityHelpers: + """Coverage for the session-injected, openapi-visibility-scoped + ``AppService`` getters used by ``/openapi/v1/apps*``. These helpers + centralise the "row exists + status normal + openapi-visibility + gate passes" check so the controller can stay free of SQL. + """ + + def test_get_app_by_id_is_plain_session_get(self): + """``get_app_by_id`` must NOT apply status / visibility filters + — callers (e.g. the openapi auth pipeline) need to differentiate + 404 (missing) from 403 (``enable_api`` off) and would lose that + signal if the helper coalesced both into ``None``. + """ + mock_session = MagicMock() + sentinel_app = MagicMock(spec=App) + sentinel_app.status = "archived" # explicitly NOT "normal" + mock_session.get.return_value = sentinel_app + + assert AppService.get_app_by_id(mock_session, "app-uuid") is sentinel_app + mock_session.get.assert_called_once_with(App, "app-uuid") + + def test_get_app_by_id_returns_none_when_missing(self): + mock_session = MagicMock() + mock_session.get.return_value = None + + assert AppService.get_app_by_id(mock_session, "missing") is None + + def test_get_visible_app_by_id_returns_app_when_visible(self): + mock_session = MagicMock() + app = MagicMock(spec=App) + app.status = "normal" + mock_session.get.return_value = app + + with patch("services.app_service.is_openapi_visible", return_value=True): + assert AppService.get_visible_app_by_id(mock_session, "app-uuid") is app + + mock_session.get.assert_called_once_with(App, "app-uuid") + + def test_get_visible_app_by_id_returns_none_when_row_missing(self): + mock_session = MagicMock() + mock_session.get.return_value = None + + assert AppService.get_visible_app_by_id(mock_session, "missing") is None + + def test_get_visible_app_by_id_returns_none_when_status_not_normal(self): + """Soft-deleted/archived rows must not surface on the openapi + surface — the helper hides them by returning ``None``. + """ + mock_session = MagicMock() + app = MagicMock(spec=App) + app.status = "archived" + mock_session.get.return_value = app + + with patch("services.app_service.is_openapi_visible", return_value=True): + assert AppService.get_visible_app_by_id(mock_session, "app-uuid") is None + + def test_get_visible_app_by_id_returns_none_when_visibility_gate_rejects(self): + """``is_openapi_visible`` is the per-row counterpart to + ``apply_openapi_gate`` — when it returns False the helper must + treat the row as invisible (not "found but unauthorized"). + """ + mock_session = MagicMock() + app = MagicMock(spec=App) + app.status = "normal" + mock_session.get.return_value = app + + with patch("services.app_service.is_openapi_visible", return_value=False): + assert AppService.get_visible_app_by_id(mock_session, "app-uuid") is None + + def test_find_visible_apps_by_name_returns_scalars_through_visibility_gate(self): + """Tenant-scoped name lookup. The helper passes the SELECT through + ``apply_openapi_gate`` and materialises ``.scalars()`` into a list + so the controller can branch on length (404 / single / 409). + """ + mock_session = MagicMock() + rows = [MagicMock(spec=App), MagicMock(spec=App)] + mock_session.execute.return_value.scalars.return_value = iter(rows) + + with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q) as gate: + out = AppService.find_visible_apps_by_name(mock_session, name="my-app", tenant_id="tenant-1") + + assert out == rows + # Visibility gate must wrap the SELECT exactly once. + gate.assert_called_once() + mock_session.execute.assert_called_once() + + def test_find_visible_apps_by_name_returns_empty_list_on_no_match(self): + mock_session = MagicMock() + mock_session.execute.return_value.scalars.return_value = iter([]) + + with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q): + out = AppService.find_visible_apps_by_name(mock_session, name="nope", tenant_id="tenant-1") + + assert out == [] + + def test_find_visible_apps_by_ids_short_circuits_on_empty_input(self): + """Empty id list must not emit ``WHERE id IN ()`` — Postgres + rejects empty IN lists and the call is a guaranteed no-op + anyway. The helper returns ``[]`` without touching the session. + """ + mock_session = MagicMock() + + assert AppService.find_visible_apps_by_ids(mock_session, []) == [] + mock_session.execute.assert_not_called() + + def test_find_visible_apps_by_ids_passes_through_visibility_gate(self): + """Bulk fetch routes through ``apply_openapi_gate`` exactly once + and materialises the scalar rows. **No** status filter is + applied here — the EE permitted-external pipeline filters + non-normal hits in Python so its page count stays anchored. + """ + mock_session = MagicMock() + rows = [MagicMock(spec=App), MagicMock(spec=App)] + mock_session.execute.return_value.scalars.return_value.all.return_value = rows + + with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q) as gate: + out = AppService.find_visible_apps_by_ids(mock_session, ["a", "b"]) + + assert out == rows + gate.assert_called_once() + mock_session.execute.assert_called_once() diff --git a/api/tests/unit_tests/services/test_oauth_device_flow.py b/api/tests/unit_tests/services/test_oauth_device_flow.py new file mode 100644 index 0000000000..b2e95c93a3 --- /dev/null +++ b/api/tests/unit_tests/services/test_oauth_device_flow.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import uuid +from unittest.mock import MagicMock + +from libs.oauth_bearer import TOKEN_CACHE_KEY_FMT, AuthContext, SubjectType +from services.oauth_device_flow import ( + list_active_sessions, + revoke_oauth_token, + subject_match_clauses, + token_belongs_to_subject, +) + + +def _account_ctx() -> AuthContext: + return AuthContext( + subject_type=SubjectType.ACCOUNT, + subject_email="user@example.com", + subject_issuer="dify:account", + account_id=uuid.uuid4(), + client_id="difyctl", + scopes=frozenset({"full"}), + token_id=uuid.uuid4(), + source="oauth_account", + expires_at=None, + token_hash="h1", + verified_tenants={}, + ) + + +def _sso_ctx() -> AuthContext: + return AuthContext( + subject_type=SubjectType.EXTERNAL_SSO, + subject_email="sso@partner.com", + subject_issuer="https://idp.partner.com", + account_id=None, + client_id="difyctl", + scopes=frozenset({"apps:run"}), + token_id=uuid.uuid4(), + source="oauth_external_sso", + expires_at=None, + token_hash="h1", + verified_tenants={}, + ) + + +# --------------------------------------------------------------------------- +# subject_match_clauses +# --------------------------------------------------------------------------- + + +def test_subject_match_clauses_account_matches_only_account_id(): + clauses = subject_match_clauses(_account_ctx()) + assert len(clauses) == 1 + assert "account_id" in str(clauses[0]) + + +def test_subject_match_clauses_external_sso_requires_null_account_id(): + """External SSO must additionally require ``account_id IS NULL`` so a + same-email account-flow row from a federated tenant cannot be + enumerated/revoked through an SSO bearer. + """ + clauses = subject_match_clauses(_sso_ctx()) + assert len(clauses) == 3 + rendered = " ".join(str(c) for c in clauses) + assert "subject_email" in rendered + assert "subject_issuer" in rendered + assert "account_id IS NULL" in rendered + + +# --------------------------------------------------------------------------- +# revoke_oauth_token +# --------------------------------------------------------------------------- + + +def test_revoke_oauth_token_invalidates_redis_cache_when_live_hash_seen(): + """Happy path: snapshot finds a live ``token_hash`` → UPDATE runs + + Redis cache entry is DEL'd so the next bearer probe re-reads the now + revoked row from DB. + """ + session = MagicMock() + session.query.return_value.filter.return_value.one_or_none.return_value = ("live-hash",) + + redis = MagicMock() + + revoke_oauth_token(session, redis, "token-id") + + assert session.execute.called # UPDATE ... WHERE revoked_at IS NULL + assert session.commit.called + redis.delete.assert_called_once_with(TOKEN_CACHE_KEY_FMT.format(hash="live-hash")) + + +def test_revoke_oauth_token_is_idempotent_when_already_revoked(): + """Second call (or race-loser): no live hash → UPDATE still runs (it + is itself idempotent thanks to ``WHERE revoked_at IS NULL``) but the + Redis invalidation is skipped because there's no cache entry to + drop. + """ + session = MagicMock() + session.query.return_value.filter.return_value.one_or_none.return_value = None + + redis = MagicMock() + + revoke_oauth_token(session, redis, "token-id") + + assert session.execute.called + assert session.commit.called + redis.delete.assert_not_called() + + +# --------------------------------------------------------------------------- +# list_active_sessions / token_belongs_to_subject +# --------------------------------------------------------------------------- + + +def test_list_active_sessions_returns_session_execute_rows(): + """Thin delegation: the helper materialises whatever + ``session.execute(...).scalars().all()`` returns into a list. The + ``.scalars()`` step unwraps each one-element ``Row`` so callers see + bare ``OAuthAccessToken`` entities (matches the declared return + type). + """ + from datetime import UTC, datetime + + session = MagicMock() + fake_rows = [MagicMock(), MagicMock()] + session.execute.return_value.scalars.return_value.all.return_value = fake_rows + + out = list_active_sessions(session, _account_ctx(), datetime.now(UTC)) + + assert out == fake_rows + assert session.execute.called + + +def test_token_belongs_to_subject_true_when_row_present(): + session = MagicMock() + session.execute.return_value.first.return_value = ("some-id",) + + assert token_belongs_to_subject(session, "token-id", _account_ctx()) is True + + +def test_token_belongs_to_subject_false_when_no_row(): + session = MagicMock() + session.execute.return_value.first.return_value = None + + assert token_belongs_to_subject(session, "token-id", _account_ctx()) is False diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000000..9747e29156 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,7 @@ +dist/ +coverage/ +node_modules/ +*.tsbuildinfo +.vitest-cache/ +docs/specs/ +context/ \ No newline at end of file diff --git a/cli/AGENTS.md b/cli/AGENTS.md new file mode 100644 index 0000000000..0d579af2c7 --- /dev/null +++ b/cli/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md — difyctl (TypeScript CLI) + +TypeScript port of difyctl. Stack: custom CLI framework (`src/framework/`), Node 22+, ESM, ky for HTTP, vitest, eslint via @antfu/eslint-config. + +> Architecture patterns, scaffolding recipe, printer chain, strategy pattern, testing conventions, anti-patterns: see **[`ARD.md`]**. + +## Code rules + +- **Spaces, not tabs.** +- **Minimum comments.** Code speak for self. Comment only non-obvious WHY — hidden constraints, subtle invariants, bug-workaround notes. Never restate code. Never reference tasks, PRs, current callers. +- **No magic strings or numbers.** Enums or named constants for bounded value sets. +- **No long positional arg lists.** Use options objects. +- **No long if/switch ladders on discriminator.** Polymorphism, dispatch tables, or strategy pattern. Name concept, let implementations plug in. +- **No `any`. No `unknown` outside genuine wire boundaries** (HTTP body parse, env vars). Narrow types everywhere else. +- **Avoid `!` non-null assertions.** Narrow instead. +- **`readonly` on inputs not mutated.** +- **Discriminated unions** for variant data (SSE events, run outputs, error shapes), not optional-field bags. +- **No backwards-compat shims.** No re-exports of old names, no `// removed:` markers, no deprecation notes. Delete, update callers. +- **No new dependencies without explicit approval.** +- **No CLI behavior changes in refactor commit.** Same flags, same output, same exit codes. +- **Every leaf command extends `DifyCommand`.** Add `static agentGuide` string when command benefits from agent workflow docs — see `src/commands/AGENTS.md`. + +## Layering + +| Layer | Path | Role | +| --------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| commands | `src/commands/` | Command class shells (extend `DifyCommand`). Only place framework imports run. | +| domain | `src/run/`, `src/get/`, etc. | Plain TS modules. Take typed deps via options. Testable without the framework. | +| api | `src/api/` | One typed client per resource. Each takes `KyInstance`. | +| http | `src/http/` | `createClient` + middleware (auth, retry, logging, error mapping). Only place ky runs. | +| io | `src/io/` | Streams + spinner. Fence between data-out and progress UI. | +| printers | `src/printers/` | `CompositePrintFlags` + `-o {json,yaml,name,wide,text}` matrix. | +| errors | `src/errors/` | `BaseError`, `ErrorCode` enum, `ExitCode` enum, dispatch table, `formatErrorForCli`. | +| guide | `src/commands/**//guide.ts` | Per-command agent guide string. Export `agentGuide`, assign `static agentGuide = agentGuide` in command class. Surfaced via `--help`. | +| cache | `src/cache/` | On-disk caches (app-info, etc.). | +| auth | `src/auth/` | Hosts file, token store, login flow. | +| config | `src/config/` | XDG dir resolution, config.yml load/save. | +| workspace | `src/workspace/` | Resolver: flag → env → bundle. | +| types | `src/types/` | Pure data + zod schemas for server contracts. No runtime imports outward. | + +## Command Structure + +Scaffold recipe + checklist: see `ARD.md §New command scaffold`. Full folder convention (subcommands, guide.ts): see `src/commands/AGENTS.md`. + +Layer rules: + +- Commands thin shells. Use `this.authedCtx(opts)` for bearer context; delegate to domain function. +- Domain receives deps via options; never imports `src/framework/`. +- Only `src/http/client.ts` and `src/api/*` import ky at runtime; elsewhere use `import type { KyInstance }`. +- `process.*` lives in `src/io/`, `src/config/dir.ts`, `src/util/browser.ts`. Nowhere else. +- No circular imports. `types/` pure leaf. + +## Dev commands + +```sh +pnpm install # one-time +pnpm dev [args...] # run CLI from source (no -- separator) +pnpm test # vitest +pnpm test:coverage # with coverage +pnpm type-check # tsc, no emit +pnpm lint # eslint +pnpm lint:fix # eslint --fix +pnpm build # production bundle (vp pack) +pnpm tree:gen # regenerate src/commands/tree.ts (registry) +pnpm tree:check # verify tree.ts is up-to-date with the fs +``` + +Release binaries (5 platform targets, Bun-compiled) are produced by `pnpm build:bin` (called from `.github/workflows/cli-release.yml`). + +## Tests + +- Behavior tests run against real Hono mock at `test/fixtures/dify-mock/`. No `nock`, `msw`, or `fetchMock` — every test exercises real HTTP. +- Test files co-located: `foo.test.ts` next to `foo.ts`. +- Type-check, lint, full test suite must be green before any commit. + +## Spec docs (`docs/specs/`) + +Behavior contracts. Living tree — amended in place, no version subfolders. + +**Keep:** HTTP wire shape (req/resp JSON, headers, status codes), SQL DDL, Redis keys + TTL, state transitions, audit event names + payload, error/exit codes, rate-limit values, JWS/cookie envelope claims. + +**Cut:** language type decls, internal helper sigs, decorator snippets, file-path tables, pseudocode mirroring code, "Open items"/"Handler walk"/"CI guard"/"Migration" sections, rationale (`Rejected:`/`Why X not Y`/`Historical note:`/product comparisons), release-pipeline lines, version-pinning (`in v1.0`, `post-v1.0`, milestone codes), frontmatter `date`/`status`/`author`. + +**Test:** "rewrite in Rust tomorrow, does spec hold?" HTTP/SQL/Redis stays; type defs go. + +**Rules:** behavior, not rationale. One topic per file; cross-refs = `auth.md §Storage`. Tables beat prose. Code wins on drift — update spec. + +## Out of scope for unrelated work + +Do not modify in passing: + +- `test/fixtures/dify-mock/` public surface (endpoints, JSON shapes, status codes, scenario names) — that's the dify-api contract. +- `bin/`, `scripts/`, `Makefile`, `eslint.config.js`, `tsconfig*.json`, `package.json` (unless the change is required by the task). + +## Commits + +- One concern per commit. Style: `(): ` lowercase. Body explains why if non-obvious. +- Never push, amend, force-push, or skip hooks (`--no-verify`) without explicit user approval. + +[`ARD.md`]: ARD.md diff --git a/cli/ARD.md b/cli/ARD.md new file mode 100644 index 0000000000..b8813fe920 --- /dev/null +++ b/cli/ARD.md @@ -0,0 +1,345 @@ +# ARD — Architecture & Design Reference + +Onboarding ref for `dify/cli/` contributors. Cover canonical patterns, layer contracts, scaffolding recipe, dev workflow, anti-patterns. Read before adding command or touching shared infra. + +Spec authority: [`docs/specs/`]. Specs own HTTP wire shape + server behavior; this file owns CLI code structure. + +--- + +## Project 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 + io/ IOStreams, spinner, printer chain + limit/ --limit flag parsing + types/ shared TypeScript types + util/ small pure helpers + workspace/ workspace ID resolution +``` + +--- + +## 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 files** + +| File | Responsibility | +| ---------- | --------------------------------------------------------------------------------------- | +| `index.ts` | `DifyCommand` subclass. Flag/arg declaration + `run()` wiring only. No business logic. | +| `run.ts` | Pure async function. Typed options + deps. Returns string. No `src/framework/` imports. | + +**3. Optional files — add as needed** + +| File | Purpose | +| ------------------ | --------------------------------------------------- | +| `handlers.ts` | Output format handlers (text, table, etc.) | +| `print-flags.ts` | `--output` flag → printer resolution | +| `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 +- [ ] No try/catch in `run()` — `DifyCommand.catch()` handles `BaseError` +- [ ] `run.ts` returns string; no direct stdout write +- [ ] `run.ts` no `src/framework/` imports +- [ ] HTTP client via factory dep, not direct +- [ ] `run.test.ts` written before impl (test-first) +- [ ] `pnpm tree:gen` run after adding command (updates `src/commands/tree.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(): Promise { + const { args, flags } = this.parse(MyCommand, argv) + + // Authed: authedCtx() sets outputFormat + builds context + const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format: flags.output }) + + process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })) + } +} +``` + +**`authedCtx(opts)`** — wraps `buildAuthedContext`. Sets `this.outputFormat` as side effect. Required for any command needing bearer token. + +**`catch(err)` override** — auto-handles `BaseError` with format-aware serialization. Never wrap `run()` in try/catch. Throw `BaseError`; base class catches. + +--- + +## Error handling + +Throw `BaseError`. Never throw raw `Error` for domain failures. + +```typescript +import { BaseError } from '../../errors/base.js' +import { ErrorCode } from '../../errors/codes.js' + +throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'workspace id required', + hint: 'pass --workspace or run \'difyctl auth use \'', +}) +``` + +`ErrorCode` exhaustive const object — never use raw strings. `exitFor(code)` maps to exit codes auto. `DifyCommand.catch()` calls `formatErrorForCli` with `outputFormat` so JSON/YAML consumers get machine-readable error output. + +| 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 | + +New error code: add to `ErrorCode` + map to `ExitCode` in `codes.ts`. Never scatter exit codes inline. + +--- + +## IOStreams + +I/O context passed through every layer. Carries stdout, stderr, stdin, TTY flags, `outputFormat`. + +```typescript +export type IOStreams = { + out: NodeJS.WritableStream + err: NodeJS.WritableStream + in: NodeJS.ReadableStream + isOutTTY: boolean + isErrTTY: boolean + outputFormat: string // 'json' | 'yaml' | 'name' | 'wide' | '' +} +``` + +| Factory | When | +| --------------------- | --------------------------------- | +| `realStreams(format)` | Production — wraps `process.std*` | +| `bufferStreams()` | Tests — captures output in memory | +| `nullStreams()` | When IO irrelevant | + +`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`). + +--- + +## Printer chain + +Output rendering separated from data fetching. + +1. `run.ts` returns string — rendered result. +1. `handlers.ts` defines format handlers (`TextHandler`, `TableHandler`, etc.). +1. `print-flags.ts` maps `--output` value to correct handler. + +```typescript +// run.ts +const printer = new AppPrintFlags().toPrinter(format) +return printer.print(data) +``` + +New output format: implement handler interface, register in `print-flags.ts`. Never add `if (format === 'json')` branches in `run.ts`. + +--- + +## Strategy pattern (mode dispatch) + +Singleton strategies + picker function. No switch ladders on discriminator. + +```typescript +export type RunStrategy = { + execute: (ctx: RunContext) => Promise +} + +const blocking = new BlockingStrategy() +const streamingText = new StreamingTextStrategy() +const streamingStructured = new StreamingStructuredStrategy() + +export function pickStrategy(useStream: boolean, isText: boolean): RunStrategy { + if (!useStream) + return blocking + return isText ? streamingText : streamingStructured +} +``` + +New mode = new class + one line in picker. Singletons avoid per-call allocation. + +--- + +## HTTP clients + +One file per resource under `src/api/`. Each exports class wrapping `KyInstance`. + +```typescript +export class AppsClient { + private readonly http: KyInstance + constructor(http: KyInstance) { this.http = http } + + async list(params: ListParams): Promise { /* ... */ throw new Error('elided') } + async describe(id: string, workspaceId: string, fields: string[]): Promise { /* ... */ throw new Error('elided') } +} +``` + +Inject via factory dep in `run.ts` for testability: + +```typescript +type GetAppDeps = { + appsFactory?: (http: KyInstance) => AppsClient +} +// default: (h) => new AppsClient(h) +``` + +Never instantiate clients in `index.ts`. + +--- + +## Testing + +**Test-first.** Write failing test, run to confirm fail, then implement. + +Tests live in `run.test.ts` alongside command. Test `run.ts` direct — never the `DifyCommand` class. + +```typescript +const io = bufferStreams() +const result = await runGetApp( + { format: 'json', appId: 'app-1' }, + { bundle, http: mockHttp, io, appsFactory: () => fakeClient }, +) +expect(JSON.parse(result).data).toHaveLength(1) +``` + +### dify-mock fixture server + +`test/fixtures/dify-mock/server.ts` mirrors `/openapi/v1/*`. Each test starts isolated instance: + +```typescript +import { startMock } from '../../../test/fixtures/dify-mock/server.js' + +const mock = await startMock({ scenario: 'happy' }) +// ... test against mock.url ... +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 | + +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. + +### 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) +``` + +--- + +## Scripts + +| Command | When to run | +| ----------------------- | -------------------------------------------------- | +| `pnpm dev [args]` | Run CLI from source during dev | +| `pnpm test` | Full vitest suite — run before every commit | +| `pnpm test:coverage` | Coverage report | +| `pnpm type-check` | `tsc --noEmit` — catches type errors without build | +| `pnpm lint` | ESLint check | +| `pnpm lint:fix` | ESLint auto-fix (perfectionist sort, chaining) | +| `pnpm build` | Production bundle (`vp pack`) | +| `pnpm tree:gen` | Regenerate `src/commands/tree.ts` (registry) | +| `pnpm tree:check` | Verify `tree.ts` matches the filesystem | +| `pnpm build:bin` | Cross-compile standalone binaries via Bun (CI) | + +**`pnpm tree:gen` rule:** run after adding, removing, renaming any command. The generated `tree.ts` is the runtime command registry — stale tree causes commands to be invisible at runtime. (Runs implicitly via `prebuild`/`predev`/`pretest`.) + +**README hand-maintained.** When adding a command, update the command table in `README.md` manually. + +--- + +## Lint rules that catch contributors + +Repo runs `@antfu/eslint-config` + perfectionist + unicorn. + +| Rule | What it catches | +| ---------------------------------- | -------------------------------------------------- | +| `perfectionist/sort-named-imports` | Alphabetical, case-insensitive | +| `perfectionist/sort-imports` | Relative imports last; `import type` first | +| `antfu/consistent-chaining` | Long `.foo().bar().baz()` must split across lines | +| `unicorn/no-new-array` | Use `Array.from({ length: n })` not `new Array(n)` | +| `noUncheckedIndexedAccess` (tsc) | `arr[i]` is `T \| undefined`; guard before use | + +`pnpm lint:fix` resolves perfectionist + chaining auto. + +--- + +## 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. + +--- + +## 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`; `DifyCommand.catch()` handles | +| 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` | Keep framework imports in `index.ts` only | +| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` | +| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` | +| New dependency without approval | Check first | + +[`docs/specs/`]: docs/specs/ diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..4d46465945 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,98 @@ +# 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. + +## Install + +Builds are standalone binaries (Bun-compiled) published as **GitHub Actions workflow artifacts** — no npm, no GitHub Release assets. The installer fetches the latest successful `cli-release.yml` run on `main`, verifies sha256, and copies the binary into `$HOME/.local/bin/difyctl`. + +```sh +# GH_TOKEN with `actions:read` scope is required — workflow artifact downloads +# need auth even on public repos. +export GH_TOKEN= +curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh +``` + +| Env | Default | Purpose | +| ---------------- | ----------------- | ----------------------------------------------------- | +| `GH_TOKEN` | — | GitHub PAT (or `GITHUB_TOKEN`) with `actions:read`. | +| `DIFYCTL_PREFIX` | `$HOME/.local` | Install root. Binary lands at `/bin/difyctl`. | +| `DIFYCTL_REPO` | `langgenius/dify` | Source repo. | +| `DIFYCTL_BRANCH` | `main` | Branch to pick the latest successful run from. | + +Supported targets: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `windows-x64.exe`. The shell installer covers Linux + macOS; Windows users can download the `.exe` directly from the same artifact. + +## 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 +``` + +Background docs: `difyctl help account`, `difyctl help external`, `difyctl help environment`. + +## Commands + +Run `difyctl --help` for the full list of commands. +Run `difyctl --help` for per-command reference. + +## Output formats + +| 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. + +## Configuration + +| OS | Config path | +| ------- | -------------------------------------------- | +| Linux | `${XDG_CONFIG_HOME:-$HOME/.config}/difyctl/` | +| 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. + +For every env var `difyctl` reads, run `difyctl env list` (machine-readable) or `difyctl help environment` (narrative). + +## Streaming + +`run app` uses blocking transport by default. For long-running apps (likely exceed ~30s) pass `--stream`: + +```sh +difyctl run app app-1 "tell me about cats" --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. + +## HTTP retry + +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. + +## Contributing + +See [`ARD.md`] for architecture patterns, scaffolding recipe, dev workflow. + +## License + +Apache-2.0. + +[Dify]: https://dify.ai +[`ARD.md`]: ARD.md diff --git a/cli/bin/dev.js b/cli/bin/dev.js new file mode 100755 index 0000000000..c0a1f3b977 --- /dev/null +++ b/cli/bin/dev.js @@ -0,0 +1,16 @@ +#!/usr/bin/env -S bun + +import { resolveBuildInfo } from '../scripts/lib/resolve-buildinfo.ts' + +const info = resolveBuildInfo() +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)) diff --git a/cli/bin/run.ts b/cli/bin/run.ts new file mode 100755 index 0000000000..27b99776be --- /dev/null +++ b/cli/bin/run.ts @@ -0,0 +1,10 @@ +#!/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' + +// Wrapped instead of top-level await — `bun build --bytecode` doesn't support TLA. +void (async () => { + await run(commandTree, process.argv.slice(2)) +})() diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000000..1b10986d7f --- /dev/null +++ b/cli/package.json @@ -0,0 +1,72 @@ +{ + "name": "@langgenius/difyctl", + "type": "module", + "version": "0.1.0-rc.1", + "description": "Dify command-line interface", + "difyctl": { + "channel": "rc", + "compat": { + "minDify": "1.14.0", + "maxDify": "1.15.0" + } + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "README.md", + "bin", + "dist" + ], + "engines": { + "node": "^22.22.1" + }, + "scripts": { + "build": "vp pack", + "dev": "bun bin/dev.js", + "test": "vp test", + "test:coverage": "vp test --coverage", + "lint": "eslint", + "lint:fix": "eslint --fix", + "type-check": "tsc", + "tree:gen": "bun scripts/generate-command-tree.ts", + "tree:check": "bun scripts/generate-command-tree.ts --check", + "prebuild": "pnpm tree:gen", + "predev": "pnpm tree:gen", + "pretest": "pnpm tree:gen", + "ci": "pnpm tree:check && pnpm type-check && pnpm lint && pnpm test:coverage && pnpm build", + "clean": "rm -rf dist node_modules/.cache", + "version:info": "bun scripts/print-buildinfo.ts", + "build:bin": "scripts/release-build.sh" + }, + "dependencies": { + "@dify/contracts": "workspace:*", + "@napi-rs/keyring": "catalog:", + "cli-table3": "catalog:", + "eventsource-parser": "catalog:", + "js-yaml": "catalog:", + "ky": "catalog:", + "open": "catalog:", + "ora": "catalog:", + "picocolors": "catalog:", + "std-semver": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@dify/tsconfig": "workspace:*", + "@hono/node-server": "catalog:", + "@types/js-yaml": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "hono": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/cli/scripts/cross-arch.npmrc b/cli/scripts/cross-arch.npmrc new file mode 100644 index 0000000000..246bb479db --- /dev/null +++ b/cli/scripts/cross-arch.npmrc @@ -0,0 +1,13 @@ +# Cross-arch keyring prebuilds for difyctl release builds. +# +# Pre-populates node_modules with @napi-rs/keyring native bindings for every +# release target so `bun build --compile` can embed them. Use via: +# +# NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force +# +# Do not set as a workspace default — it would bloat dev installs. +supported-architectures-os[]=linux +supported-architectures-os[]=darwin +supported-architectures-os[]=win32 +supported-architectures-cpu[]=x64 +supported-architectures-cpu[]=arm64 diff --git a/cli/scripts/generate-command-tree.test.ts b/cli/scripts/generate-command-tree.test.ts new file mode 100644 index 0000000000..2f3c4ce2f4 --- /dev/null +++ b/cli/scripts/generate-command-tree.test.ts @@ -0,0 +1,214 @@ +import type { CommandEntry } from './generate-command-tree.js' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildTree, + discoverCommands, + formatModule, + generate, + pathToTokens, + tokensToIdentifier, +} from './generate-command-tree.js' + +describe('pathToTokens', () => { + it('extracts tokens for nested command', () => { + expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')) + .toEqual(['auth', 'devices', 'list']) + }) + + it('extracts tokens for top-level command', () => { + expect(pathToTokens('src/commands/version/index.ts', 'src/commands')) + .toEqual(['version']) + }) + + it('normalizes backslashes (windows-style paths)', () => { + expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')) + .toEqual(['auth', 'login']) + }) +}) + +describe('tokensToIdentifier', () => { + it('pascal-cases joined tokens', () => { + expect(tokensToIdentifier(['auth', 'devices', 'list'])).toBe('AuthDevicesList') + expect(tokensToIdentifier(['version'])).toBe('Version') + expect(tokensToIdentifier(['run', 'app', 'resume'])).toBe('RunAppResume') + }) + + it('splits hyphenated tokens', () => { + expect(tokensToIdentifier(['agent-chat'])).toBe('AgentChat') + expect(tokensToIdentifier(['my-cmd', 'sub-thing'])).toBe('MyCmdSubThing') + }) + + it('prefixes underscore on reserved words', () => { + expect(tokensToIdentifier(['delete'])).toBe('_Delete') + }) +}) + +describe('buildTree', () => { + it('assembles a nested tree from entries', () => { + const entries = [ + { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, + { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' }, + { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, + ] + const tree = buildTree(entries) + expect(tree.subcommands.get('auth')?.command).toBeUndefined() + expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin') + expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command) + .toBe('AuthDevicesList') + expect(tree.subcommands.get('version')?.command).toBe('Version') + }) + + it('supports a parent command with its own children', () => { + const entries = [ + { tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' }, + { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' }, + ] + const tree = buildTree(entries) + const runApp = tree.subcommands.get('run')?.subcommands.get('app') + expect(runApp?.command).toBe('RunApp') + expect(runApp?.subcommands.get('resume')?.command).toBe('RunAppResume') + }) +}) + +describe('formatModule', () => { + it('produces a deterministic ESM file with imports + tree literal', () => { + const entries: CommandEntry[] = [ + { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, + { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, + { tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' }, + ] + const tree = buildTree(entries) + const out = formatModule(entries, tree) + expect(out).toBe( + `// @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.js' +import AuthDevicesList from './auth/devices/list/index.js' +import AuthLogin from './auth/login/index.js' +import Version from './version/index.js' + +export const commandTree: CommandTree = { + auth: { + subcommands: { + devices: { + subcommands: { + list: { command: AuthDevicesList, subcommands: {} }, + }, + }, + login: { command: AuthLogin, subcommands: {} }, + }, + }, + version: { command: Version, subcommands: {} }, +} +`, + ) + }) + + it('emits parent-with-own-command shape', () => { + const entries: CommandEntry[] = [ + { tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' }, + { tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' }, + ] + const tree = buildTree(entries) + const out = formatModule(entries, tree) + expect(out).toContain(`run: { + subcommands: { + app: { + command: RunApp, + subcommands: { + resume: { command: RunAppResume, subcommands: {} }, + }, + }, + }, + },`) + }) + + it('imports sorted alphabetically by import path', () => { + const entries: CommandEntry[] = [ + { tokens: ['version'], identifier: 'Version', importPath: './version/index.js' }, + { tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' }, + ] + const out = formatModule(entries, buildTree(entries)) + const authIdx = out.indexOf('AuthLogin') + const verIdx = out.indexOf('Version') + expect(authIdx).toBeLessThan(verIdx) + }) +}) + +function makeFixture(): string { + const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-')) + const commands = join(root, 'src', 'commands') + mkdirSync(join(commands, 'auth', 'login'), { recursive: true }) + writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n') + mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true }) + writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n') + mkdirSync(join(commands, '_shared'), { recursive: true }) + writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n') + mkdirSync(join(commands, 'version'), { recursive: true }) + writeFileSync(join(commands, 'version', 'index.ts'), 'export default class Version {}\n') + return root +} + +describe('discoverCommands', () => { + it('returns sorted entries, skipping _-prefixed segments', async () => { + const root = makeFixture() + const entries = await discoverCommands(join(root, 'src', 'commands')) + expect(entries.map(e => e.tokens.join('/'))).toEqual([ + 'auth/devices/list', + 'auth/login', + 'version', + ]) + expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined() + }) + + it('errors on a loose .ts file under commands/', async () => { + const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-loose-')) + const commands = join(root, 'src', 'commands') + mkdirSync(commands, { recursive: true }) + writeFileSync(join(commands, 'foo.ts'), 'export default class Foo {}\n') + await expect(discoverCommands(commands)).rejects.toThrow(/must live under their own folder/) + }) +}) + +describe('generate', () => { + it('writes tree.generated.ts on default mode', async () => { + const root = makeFixture() + const commandsDir = join(root, 'src', 'commands') + const result = await generate({ commandsDir, mode: 'write' }) + expect(result.mode).toBe('write') + const target = join(commandsDir, 'tree.generated.ts') + expect(existsSync(target)).toBe(true) + const content = readFileSync(target, 'utf8') + expect(content).toContain('@generated') + expect(content).toContain('import AuthLogin') + expect(content).toContain('import AuthDevicesList') + expect(content).toContain('import Version') + }) + + it('returns ok: true on --check when file matches', async () => { + const root = makeFixture() + const commandsDir = join(root, 'src', 'commands') + await generate({ commandsDir, mode: 'write' }) + const result = await generate({ commandsDir, mode: 'check' }) + if (result.mode !== 'check') + throw new Error('expected check mode') + expect(result.ok).toBe(true) + }) + + it('returns ok: false on --check when file is stale', async () => { + const root = makeFixture() + const commandsDir = join(root, 'src', 'commands') + await generate({ commandsDir, mode: 'write' }) + writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n') + const result = await generate({ commandsDir, mode: 'check' }) + if (result.mode !== 'check') + throw new Error('expected check mode') + expect(result.ok).toBe(false) + if (!result.ok) + expect(result.diff).toBeDefined() + }) +}) diff --git a/cli/scripts/generate-command-tree.ts b/cli/scripts/generate-command-tree.ts new file mode 100644 index 0000000000..f6ea768027 --- /dev/null +++ b/cli/scripts/generate-command-tree.ts @@ -0,0 +1,318 @@ +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' + +const RESERVED_JS_KEYWORDS = new Set([ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'enum', + 'export', + 'extends', + 'false', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'null', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'true', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', +]) + +export type CommandEntry = { + readonly tokens: readonly string[] + readonly identifier: string + readonly importPath: string +} + +export type TreeNode = { + command?: string + subcommands: Map +} + +export function pathToTokens(filePath: string, commandsRoot: string): string[] { + const normalized = filePath.replace(/\\/g, '/') + const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '') + const trimmed = normalized.startsWith(`${root}/`) + ? normalized.slice(root.length + 1) + : normalized + const withoutIndex = trimmed.replace(/\/index\.ts$/, '') + return withoutIndex.split('/').filter(s => s.length > 0) +} + +function capitalize(part: string): string { + if (part.length === 0) + return '' + return part[0]!.toUpperCase() + part.slice(1) +} + +export function tokensToIdentifier(tokens: readonly string[]): string { + const id = tokens + .flatMap(t => t.split(/[-_]/)) + .map(capitalize) + .join('') + if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) + return `_${id}` + return id +} + +export function buildTree(entries: readonly CommandEntry[]): TreeNode { + const root: TreeNode = { subcommands: new Map() } + for (const entry of entries) { + let node = root + for (let i = 0; i < entry.tokens.length; i++) { + const tok = entry.tokens[i]! + let next = node.subcommands.get(tok) + if (!next) { + next = { subcommands: new Map() } + node.subcommands.set(tok, next) + } + node = next + } + node.command = entry.identifier + } + return root +} + +const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDIT. +// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI. +` + +function compareStrings(a: string, b: string): number { + if (a < b) + return -1 + if (a > b) + return 1 + return 0 +} + +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.js'`] + for (const e of sorted) + lines.push(`import ${e.identifier} from '${e.importPath}'`) + return lines.join('\n') +} + +function emitNode(node: TreeNode, indent: string): string { + const inner = `${indent} ` + const keys = [...node.subcommands.keys()].sort() + const parts: string[] = [] + + if (node.command !== undefined) + parts.push(`${inner}command: ${node.command},`) + + if (keys.length === 0) { + parts.push(`${inner}subcommands: {},`) + } + else { + parts.push(`${inner}subcommands: {`) + for (const key of keys) { + const child = node.subcommands.get(key)! + parts.push(emitEntry(key, child, `${inner} `)) + } + parts.push(`${inner}},`) + } + + return parts.join('\n') +} + +function emitEntry(key: string, node: TreeNode, indent: string): string { + const isLeaf = node.subcommands.size === 0 && node.command !== undefined + if (isLeaf) + return `${indent}${key}: { command: ${node.command}, subcommands: {} },` + + return [ + `${indent}${key}: {`, + emitNode(node, indent), + `${indent}},`, + ].join('\n') +} + +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` +} + +async function walk(dir: string): Promise { + const out: string[] = [] + const entries = await readdir(dir, { withFileTypes: true }) + for (const e of entries) { + const full = join(dir, e.name) + if (e.isDirectory()) + out.push(...await walk(full)) + else if (e.isFile()) + out.push(full) + } + return out +} + +function toPosix(p: string): string { + return p.split(sep).join('/') +} + +export async function discoverCommands(commandsDir: string): Promise { + const all = await walk(commandsDir) + const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts')) + + const loose: string[] = [] + for (const abs of tsFiles) { + const rel = toPosix(relative(commandsDir, abs)) + if (isExcludedCommandPath(rel)) + continue + if (rel === 'tree.ts' || rel === 'tree.generated.ts') + continue + // Only flag files directly under commands/ (no path separator — no parent folder) + if (!rel.includes('/')) + loose.push(rel) + } + if (loose.length > 0) { + const list = loose.map(p => ` - src/commands/${p}`).join('\n') + throw new Error( + `commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`, + ) + } + + const entries: CommandEntry[] = [] + for (const abs of tsFiles) { + const rel = toPosix(relative(commandsDir, abs)) + if (isExcludedCommandPath(rel)) + continue + if (!rel.endsWith('/index.ts')) + continue + const tokens = pathToTokens(rel, '') + if (tokens.length === 0) + continue + if (tokens[0]!.startsWith('-')) + throw new Error(`command token cannot start with '-': ${rel}`) + entries.push({ + tokens, + identifier: tokensToIdentifier(tokens), + importPath: `./${tokens.join('/')}/index.js`, + }) + } + + entries.sort((a, b) => compareStrings(a.importPath, b.importPath)) + + if (entries.length === 0) + throw new Error(`no commands found under ${commandsDir}`) + + assertUniqueIdentifiers(entries) + return entries +} + +function assertUniqueIdentifiers(entries: readonly CommandEntry[]): void { + const seen = new Map() + for (const e of entries) { + const prev = seen.get(e.identifier) + if (prev !== undefined) + throw new Error(`identifier collision: ${e.identifier} from ${prev} and ${e.importPath}`) + seen.set(e.identifier, e.importPath) + } +} + +export type GenerateOptions = { + readonly commandsDir: string + readonly mode: 'write' | 'check' +} + +export type GenerateResult + = | { mode: 'write', wrote: boolean, path: string } + | { mode: 'check', ok: true, path: string } + | { mode: 'check', ok: false, path: string, diff: string } + +export async function generate(opts: GenerateOptions): Promise { + const entries = await discoverCommands(opts.commandsDir) + const tree = buildTree(entries) + const content = formatModule(entries, tree) + const target = join(opts.commandsDir, 'tree.generated.ts') + + if (opts.mode === 'check') { + let onDisk = '' + try { + onDisk = await readFile(target, 'utf8') + } + catch { + onDisk = '' + } + if (onDisk === content) + return { mode: 'check', ok: true, path: target } + return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) } + } + + const tmp = `${target}.tmp-${process.pid}-${Date.now()}` + await writeFile(tmp, content, 'utf8') + await rename(tmp, target) + return { mode: 'write', wrote: true, path: target } +} + +function shortDiff(a: string, b: string): string { + const aLines = a.split('\n') + const bLines = b.split('\n') + const lines: string[] = [] + const max = Math.max(aLines.length, bLines.length) + for (let i = 0; i < max; i++) { + if (aLines[i] !== bLines[i]) { + if (aLines[i] !== undefined) + lines.push(`- ${aLines[i]}`) + if (bLines[i] !== undefined) + lines.push(`+ ${bLines[i]}`) + } + } + return lines.slice(0, 40).join('\n') +} + +async function main(): Promise { + const here = fileURLToPath(import.meta.url) + const commandsDir = join(here, '..', '..', 'src', 'commands') + const checkMode = process.argv.includes('--check') + const result = await generate({ commandsDir, mode: checkMode ? 'check' : 'write' }) + + if (result.mode === 'write') { + process.stderr.write(`tree:gen wrote ${result.path}\n`) + return + } + if (result.ok) { + process.stderr.write(`tree:check ok\n`) + return + } + process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`) + process.exit(1) +} + +const invokedDirectly = process.argv[1] !== undefined + && fileURLToPath(import.meta.url) === process.argv[1] + +if (invokedDirectly) + await main() diff --git a/cli/scripts/install-cli.sh b/cli/scripts/install-cli.sh new file mode 100755 index 0000000000..955dcd5a9e --- /dev/null +++ b/cli/scripts/install-cli.sh @@ -0,0 +1,118 @@ +#!/bin/sh +# install-cli.sh — one-line difyctl installer from the latest GitHub Actions build. +# +# usage: +# GH_TOKEN= curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh +# +# env: DIFYCTL_PREFIX (default $HOME/.local), DIFYCTL_REPO (default langgenius/dify), +# DIFYCTL_BRANCH (default main), +# GH_TOKEN/GITHUB_TOKEN (required — workflow artifact zip downloads need +# auth even on public repos; minimum scope: actions:read). +# requires: curl, uname, jq, unzip, sha256sum or shasum. + +set -eu + +REPO="${DIFYCTL_REPO:-langgenius/dify}" +BRANCH="${DIFYCTL_BRANCH:-main}" +PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" +WORKFLOW_FILE="cli-release.yml" +TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + +err() { printf '%s\n' "install-cli: $*" >&2; } +die() { err "$*"; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +need curl +need uname +need jq +need unzip + +[ -n "$TOKEN" ] || die "GH_TOKEN (or GITHUB_TOKEN) is required — workflow artifact downloads need auth" + +gh_curl() { curl -fsSL -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github.v3+json" "$@"; } + +if command -v sha256sum >/dev/null 2>&1; then + HASH="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + HASH="shasum -a 256" +else + die "need sha256sum or shasum" +fi + +case "$(uname -s)" in + Linux*) os=linux ;; + Darwin*) os=darwin ;; + *) die "unsupported OS: $(uname -s) (use the Windows .exe directly)" ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + arm64|aarch64) arch=arm64 ;; + *) die "unsupported arch: $(uname -m)" ;; +esac + +target="${os}-${arch}" + +# 1. Find the latest successful workflow run on the branch +api_url="https://api.github.com/repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${BRANCH}&status=success&per_page=1" +run_id=$(gh_curl "$api_url" | jq -r '.workflow_runs[0].id') + +if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then + die "could not find a successful workflow run for ${WORKFLOW_FILE} on branch ${BRANCH}" +fi + +# 2. Find the artifact from that run +artifacts_url="https://api.github.com/repos/${REPO}/actions/runs/${run_id}/artifacts" +artifact_info=$(gh_curl "$artifacts_url" | jq '.artifacts[0]') +artifact_id=$(printf '%s' "$artifact_info" | jq -r '.id') +artifact_name=$(printf '%s' "$artifact_info" | jq -r '.name') + +if [ -z "$artifact_id" ] || [ "$artifact_id" = "null" ]; then + die "could not find any artifacts for workflow run ${run_id}" +fi + +# 3. Download and unzip the artifact (one zip with all platform binaries + checksums) +tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install) +trap 'rm -rf "$tmp"' EXIT INT TERM + +download_url="https://api.github.com/repos/${REPO}/actions/artifacts/${artifact_id}/zip" +printf 'downloading artifact %s (run %s)...\n' "$artifact_name" "$run_id" +gh_curl -L "$download_url" -o "${tmp}/artifact.zip" +unzip -q "${tmp}/artifact.zip" -d "${tmp}/artifact" + +# 4. Locate the binary for this host + the checksum manifest +asset_path=$(ls "${tmp}/artifact"/difyctl-v*-"${target}" 2>/dev/null | head -1) +[ -n "$asset_path" ] || die "no binary matching target ${target} in artifact" +asset=$(basename "$asset_path") +cli_version=${asset#difyctl-v} +cli_version=${cli_version%-${target}} +checksums="difyctl-v${cli_version}-checksums.txt" + +[ -f "${tmp}/artifact/${checksums}" ] || die "checksum file ${checksums} not found in artifact" + +# 5. Verify checksum +( + cd "${tmp}/artifact" + grep " ${asset}\$" "$checksums" | $HASH -c - +) || die "checksum mismatch for ${asset}" + +# 6. Install: copy binary to /bin/difyctl and chmod +x +bin_dir="${PREFIX}/bin" +mkdir -p "$bin_dir" +target_bin="${bin_dir}/difyctl" +cp "${tmp}/artifact/${asset}" "$target_bin" +chmod +x "$target_bin" + +printf '\ndifyctl v%s installed: %s\n' "$cli_version" "$target_bin" + +case ":${PATH}:" in + *":${bin_dir}:"*) + "$target_bin" version >/dev/null 2>&1 \ + && printf 'verify: run "difyctl version"\n' \ + || err "binary present but failed to execute; check ${target_bin}" + ;; + *) + printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir" + printf ' export PATH="%s:$PATH"\n' "$bin_dir" + ;; +esac diff --git a/cli/scripts/install-local.sh b/cli/scripts/install-local.sh new file mode 100755 index 0000000000..3a1b76f78d --- /dev/null +++ b/cli/scripts/install-local.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# install-local.sh — install difyctl from a locally built tarball. +# Run via: pnpm install:local +set -eu + +PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" +SHARE_DIR="${PREFIX}/share/difyctl" +BIN_DIR="${PREFIX}/bin" + +case "$(uname -s)" in + Linux*) os=linux ;; + Darwin*) os=darwin ;; + *) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=x64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; +esac + +# Accept an optional directory path as the first argument. +# Default to the cli/dist directory if not provided. +ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist" && pwd)}" +TARBALL="$(ls "${ARTIFACT_DIR}"/difyctl-*-${os}-${arch}.tar.xz 2>/dev/null | head -1)" + +if [ -z "$TARBALL" ]; then + echo "no tarball found for ${os}-${arch} in ${ARTIFACT_DIR}" >&2 + echo "run: pnpm pack:tarballs" >&2 + exit 1 +fi + +echo "installing from $(basename "$TARBALL") ..." +rm -rf "$SHARE_DIR" +mkdir -p "$SHARE_DIR" "$BIN_DIR" +tar -xJf "$TARBALL" -C "$SHARE_DIR" --strip-components=1 +ln -sf "${SHARE_DIR}/bin/difyctl" "${BIN_DIR}/difyctl" +echo "installed: ${BIN_DIR}/difyctl" + +case ":${PATH}:" in + *":${BIN_DIR}:"*) ;; + *) echo "note: add ${BIN_DIR} to your PATH" ;; +esac diff --git a/cli/scripts/lib/common.sh b/cli/scripts/lib/common.sh new file mode 100755 index 0000000000..3d32cb703d --- /dev/null +++ b/cli/scripts/lib/common.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# scripts/lib/common.sh — shared shell helpers for cli/ scripts. + +[[ -n "${DIFYCTL_LIB_COMMON_SH:-}" ]] && return 0 +readonly DIFYCTL_LIB_COMMON_SH=1 + +log::info() { printf '\033[36m[info]\033[0m %s\n' "$*" >&2; } +log::warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; } +log::err() { printf '\033[31m[err ]\033[0m %s\n' "$*" >&2; } + +die() { log::err "$*"; exit 1; } + +# Resolve the cli/ directory (parent of scripts/). +cli::root() { + local dir + dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + printf '%s' "$dir" +} + +require() { + command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1${2:+ — $2}" +} diff --git a/cli/scripts/lib/resolve-buildinfo.ts b/cli/scripts/lib/resolve-buildinfo.ts new file mode 100644 index 0000000000..ee0cc7b550 --- /dev/null +++ b/cli/scripts/lib/resolve-buildinfo.ts @@ -0,0 +1,92 @@ +import type { ExecSyncOptions } from 'node:child_process' +import { execSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const BUILD_CHANNELS = ['dev', 'rc', 'stable'] as const +export type BuildChannel = (typeof BUILD_CHANNELS)[number] + +export type BuildInfo = { + version: string + commit: string + buildDate: string + channel: BuildChannel + minDify: string + maxDify: string +} + +export type Env = Record + +export type GitProbe = (cmd: string) => string | null + +const GIT_PROBE_OPTS: ExecSyncOptions = { + stdio: ['ignore', 'pipe', 'ignore'], +} + +export const defaultGitProbe: GitProbe = (cmd) => { + try { + return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null + } + catch { + return null + } +} + +type PackageManifest = { + difyctl?: { + channel?: string + compat?: { minDify?: string, maxDify?: string } + } +} + +export type PackageReader = () => PackageManifest + +// Default reader resolves cli/package.json relative to this file so the same +// helper works whether invoked from vite.config.ts, bin/dev.js, or release.sh. +const defaultPackageReader: PackageReader = () => { + try { + const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json') + return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest + } + catch { + return {} + } +} + +export type ResolveOptions = { + env?: Env + git?: GitProbe + now?: () => Date + pkg?: PackageReader +} + +export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo { + const env = opts.env ?? process.env + const git = opts.git ?? defaultGitProbe + const now = opts.now ?? (() => new Date()) + const pkg = (opts.pkg ?? defaultPackageReader)() + + const channel = env.DIFYCTL_CHANNEL ?? pkg.difyctl?.channel ?? 'dev' + if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) { + throw new Error( + `invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`, + ) + } + + const version + = env.DIFYCTL_VERSION + ?? git('git describe --tags --dirty --always') + ?? '0.0.0-dev' + + 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 } +} diff --git a/cli/scripts/print-buildinfo.ts b/cli/scripts/print-buildinfo.ts new file mode 100644 index 0000000000..69f448c8d4 --- /dev/null +++ b/cli/scripts/print-buildinfo.ts @@ -0,0 +1,9 @@ +import { resolveBuildInfo } from './lib/resolve-buildinfo.js' + +const info = resolveBuildInfo() +process.stdout.write( + `version: ${info.version}\n` + + `commit: ${info.commit}\n` + + `built: ${info.buildDate}\n` + + `channel: ${info.channel}\n`, +) diff --git a/cli/scripts/release-build.sh b/cli/scripts/release-build.sh new file mode 100755 index 0000000000..500af0f728 --- /dev/null +++ b/cli/scripts/release-build.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# scripts/release-build.sh — cross-compile difyctl to standalone binaries +# for every release target via `bun build --compile`. +# +# Bun consumes bin/run.ts (which imports from src/) directly — no `pnpm build` +# / dist/ step needed. +# +# Prereqs: +# - All @napi-rs/keyring native variants present in node_modules. Use +# `NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force` +# to populate them. +# +# 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` +# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify` +# DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown") +# DIFYCTL_BUILD_DATE — current UTC time +# +# Output: dist/bin/difyctl-v-[.exe] + +set -euo pipefail + +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${_dir}/lib/common.sh" + +require bun + +cli_root="$(cli::root)" +entry="${cli_root}/bin/run.ts" +out_dir="${cli_root}/dist/bin" + +read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; } + +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)}" + +[[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json" + +[[ -f "$entry" ]] || die "entry not found: $entry" + +rm -rf "$out_dir" +mkdir -p "$out_dir" + +# Build-info globals (referenced as bare identifiers in src/version/info.ts). +# Each value must be a JS expression — wrap strings as JSON-quoted strings. +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}\"" +) + +# Bun --target -> release asset suffix (asset name omits the bun- prefix +# and uses Node-style platform names; .exe is appended for Windows). +targets=( + "bun-linux-x64:linux-x64" + "bun-linux-arm64:linux-arm64" + "bun-darwin-x64:darwin-x64" + "bun-darwin-arm64:darwin-arm64" + "bun-windows-x64:windows-x64" +) + +for spec in "${targets[@]}"; do + bun_target="${spec%%:*}" + asset_target="${spec##*:}" + suffix="" + case "$bun_target" in + bun-windows-*) suffix=".exe" ;; + esac + + out="${out_dir}/difyctl-v${CLI_VERSION}-${asset_target}${suffix}" + log::info "compiling ${asset_target} -> $(basename "$out")..." + bun build "$entry" \ + --target="$bun_target" \ + --compile \ + --minify \ + "${defines[@]}" \ + --outfile="$out" >/dev/null +done + +log::info "built $(find "$out_dir" -type f | wc -l | tr -d ' ') binaries:" +ls -lh "$out_dir" >&2 diff --git a/cli/scripts/release-validate-manifest.sh b/cli/scripts/release-validate-manifest.sh new file mode 100755 index 0000000000..44e88700fa --- /dev/null +++ b/cli/scripts/release-validate-manifest.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# scripts/release-validate-manifest.sh — validate cli/package.json release fields. + +set -euo pipefail + +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${_dir}/lib/common.sh" + +cd "$(cli::root)" + +SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + +version=$(node -p "require('./package.json').version") +channel=$(node -p "require('./package.json').difyctl.channel") +min_dify=$(node -p "require('./package.json').difyctl.compat.minDify") +max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify") + +[[ "$version" =~ $SEMVER_RE ]] || die "invalid version: ${version}" + +case "$channel" in + rc|stable) ;; + *) die "invalid difyctl.channel: ${channel} (expected rc | stable)" ;; +esac + +[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}" +[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}" + +case "$min_dify" in *[xX*]*) die "wildcards not allowed in minDify: ${min_dify}" ;; esac +case "$max_dify" in *[xX*]*) die "wildcards not allowed in maxDify: ${max_dify}" ;; esac + +cmp=$(node -e " +const a = process.argv[1].split('-')[0].split('.').map(Number) +const b = process.argv[2].split('-')[0].split('.').map(Number) +for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { console.log(a[i] < b[i] ? -1 : 1); process.exit(0) } +} +console.log(0) +" "$min_dify" "$max_dify") + +[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})" + +log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}" diff --git a/cli/scripts/release-write-checksums.sh b/cli/scripts/release-write-checksums.sh new file mode 100755 index 0000000000..b106091324 --- /dev/null +++ b/cli/scripts/release-write-checksums.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# scripts/release-write-checksums.sh — write sha256 manifest for release binaries. +# +# Required env: CLI_VERSION (e.g. 0.1.0-rc.1). Output: +# cli/dist/bin/difyctl-v-checksums.txt + +set -euo pipefail + +_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${_dir}/lib/common.sh" + +: "${CLI_VERSION:?CLI_VERSION is required}" + +cd "$(cli::root)/dist/bin" + +manifest="difyctl-v${CLI_VERSION}-checksums.txt" +> "$manifest" + +if command -v sha256sum >/dev/null 2>&1; then + hash_cmd="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + hash_cmd="shasum -a 256" +else + die "no sha256 hasher found (need sha256sum or shasum)" +fi + +found=0 +for bin in difyctl-v"${CLI_VERSION}"-*; do + [[ -f "$bin" ]] || continue + [[ "$bin" == "$manifest" ]] && continue + $hash_cmd "$bin" >> "$manifest" + found=$((found + 1)) +done + +[[ "$found" -gt 0 ]] || die "no binaries matching difyctl-v${CLI_VERSION}-* in dist/bin/" + +log::info "wrote ${manifest} (${found} entries)" diff --git a/cli/scripts/run-smoke.ts b/cli/scripts/run-smoke.ts new file mode 100644 index 0000000000..7c4e776393 --- /dev/null +++ b/cli/scripts/run-smoke.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S bun +import { execSync } from 'node:child_process' + +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) +} + +const env = { ...process.env, DIFY_BASE_URL: baseUrl } + +function cli(args: string): string { + return execSync(`bun bin/dev.js ${args}`, { env, encoding: 'utf8' }) +} + +const checks: Check[] = [ + { name: 'config show', run: () => { cli('config show') } }, + { name: 'get workspace', run: () => { + if (!cli('get workspace').includes('id')) + throw new Error('no workspace listed') + } }, + { name: 'get apps', run: () => { cli('get apps') } }, + { name: 'difyctl version prints compat', run: () => { + if (!cli('version').includes('compat:')) + throw new Error('no compat line') + } }, +] + +let failed = 0 +for (const c of checks) { + try { + c.run() + console.log(`[x] ${c.name}`) + } + catch (err) { + failed++ + console.log(`[ ] ${c.name} — ${(err as Error).message}`) + } +} +console.log(`\n${checks.length - failed}/${checks.length} checks passed`) +process.exit(failed > 0 ? 1 : 0) diff --git a/cli/scripts/uninstall-local.sh b/cli/scripts/uninstall-local.sh new file mode 100755 index 0000000000..42b6c202d1 --- /dev/null +++ b/cli/scripts/uninstall-local.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# uninstall-local.sh — remove a locally installed difyctl. +# Run via: pnpm uninstall:local +set -eu + +PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" +SHARE_DIR="${PREFIX}/share/difyctl" +BIN_LINK="${PREFIX}/bin/difyctl" + +rm -rf "$SHARE_DIR" +rm -f "$BIN_LINK" +echo "removed ${SHARE_DIR} and ${BIN_LINK}" diff --git a/cli/src/api/account-sessions.ts b/cli/src/api/account-sessions.ts new file mode 100644 index 0000000000..102927bf8e --- /dev/null +++ b/cli/src/api/account-sessions.ts @@ -0,0 +1,22 @@ +import type { SessionListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' + +export class AccountSessionsClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async list(): Promise { + return this.http.get('account/sessions').json() + } + + async revoke(sessionId: string): Promise { + await this.http.delete(`account/sessions/${encodeURIComponent(sessionId)}`) + } + + async revokeSelf(): Promise { + await this.http.delete('account/sessions/self') + } +} diff --git a/cli/src/api/account.ts b/cli/src/api/account.ts new file mode 100644 index 0000000000..daea500eb4 --- /dev/null +++ b/cli/src/api/account.ts @@ -0,0 +1,14 @@ +import type { AccountResponse } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' + +export class AccountClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async get(): Promise { + return this.http.get('account').json() + } +} diff --git a/cli/src/api/app-meta.test.ts b/cli/src/api/app-meta.test.ts new file mode 100644 index 0000000000..b1b2cf00ab --- /dev/null +++ b/cli/src/api/app-meta.test.ts @@ -0,0 +1,91 @@ +import type { DifyMock } from '../../test/fixtures/dify-mock/server.js' +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 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { loadAppInfoCache } from '../cache/app-info.js' +import { createClient } from '../http/client.js' +import { FieldInfo, FieldParameters } from '../types/app-meta.js' +import { AppMetaClient } from './app-meta.js' +import { AppsClient } from './apps.js' + +describe('AppMetaClient', () => { + let mock: DifyMock + let dir: string + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-')) + }) + afterEach(async () => { + await mock.stop() + await rm(dir, { recursive: true, force: true }) + }) + + it('cache miss → fetch → populate; warm hit skips network', async () => { + const cache = await loadAppInfoCache({ configDir: dir }) + const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const spy = vi.spyOn(apps, 'describe') + const client = new AppMetaClient({ apps, host: mock.url, cache }) + + const m1 = await client.get('app-1', 'ws-1', [FieldInfo]) + expect(m1.info?.id).toBe('app-1') + expect(spy).toHaveBeenCalledTimes(1) + + const m2 = await client.get('app-1', 'ws-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({ configDir: dir }) + const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const spy = vi.spyOn(apps, 'describe') + const client = new AppMetaClient({ apps, host: mock.url, cache }) + + await client.get('app-1', 'ws-1', [FieldInfo]) + expect(spy).toHaveBeenCalledTimes(1) + + const full = await client.get('app-1', 'ws-1', [FieldInfo, FieldParameters]) + expect(spy).toHaveBeenCalledTimes(2) + expect(full.coveredFields.has(FieldParameters)).toBe(true) + }) + + it('expired cache entry refetches', async () => { + const cache = await loadAppInfoCache({ configDir: dir, ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') }) + const apps = new AppsClient(createClient({ host: mock.url, bearer: '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', 'ws-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', 'ws-1', [FieldInfo]) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('invalidate forces next get to fetch', async () => { + const cache = await loadAppInfoCache({ configDir: dir }) + const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const spy = vi.spyOn(apps, 'describe') + const client = new AppMetaClient({ apps, host: mock.url, cache }) + + await client.get('app-1', 'ws-1', [FieldInfo]) + expect(spy).toHaveBeenCalledTimes(1) + + await client.invalidate('app-1') + await client.get('app-1', 'ws-1', [FieldInfo]) + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('no cache: each call hits network', async () => { + const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' })) + const spy = vi.spyOn(apps, 'describe') + const client = new AppMetaClient({ apps, host: mock.url }) + + await client.get('app-1', 'ws-1', [FieldInfo]) + await client.get('app-1', 'ws-1', [FieldInfo]) + expect(spy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/cli/src/api/app-meta.ts b/cli/src/api/app-meta.ts new file mode 100644 index 0000000000..8d70385043 --- /dev/null +++ b/cli/src/api/app-meta.ts @@ -0,0 +1,45 @@ +import type { AppInfoCache } from '../cache/app-info.js' +import type { AppMeta, AppMetaFieldKey } from '../types/app-meta.js' +import type { AppsClient } from './apps.js' +import { covers, fromDescribe, mergeMeta } from '../types/app-meta.js' + +export type AppMetaClientOptions = { + readonly apps: AppsClient + readonly host: string + readonly cache?: AppInfoCache + readonly now?: () => Date +} + +export class AppMetaClient { + private readonly apps: AppsClient + 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, workspaceId: 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, workspaceId, 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-run.test.ts b/cli/src/api/app-run.test.ts new file mode 100644 index 0000000000..07d6a095a7 --- /dev/null +++ b/cli/src/api/app-run.test.ts @@ -0,0 +1,137 @@ +import type { DifyMock } from '../../test/fixtures/dify-mock/server.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { createClient } from '../http/client.js' +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(createClient({ host: mock.url, bearer: '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(createClient({ host: 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(createClient({ host: mock.url, bearer: '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(createClient({ host: mock.url, bearer: '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(createClient({ host: mock.url, bearer: '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(createClient({ host: mock.url, bearer: '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 new file mode 100644 index 0000000000..12b00e411e --- /dev/null +++ b/cli/src/api/app-run.ts @@ -0,0 +1,100 @@ +import type { KyInstance } from 'ky' +import type { SseEvent } from '../http/sse.js' +import { normalizeDifyStream } from '../http/sse-dify.js' +import { parseSSE } from '../http/sse.js' + +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 +} + +export class AppRunClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async runStream( + appId: string, + body: Record, + opts: StreamOptions = {}, + ): Promise> { + const res = await this.http.post(`apps/${encodeURIComponent(appId)}/run`, { + json: body, + headers: { Accept: 'text/event-stream' }, + retry: { limit: 0 }, + timeout: false, + signal: opts.signal, + }) + 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.http.post(`apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(taskId)}/stop`, { + json: {}, + timeout: 30_000, + }) + } + + async submitHumanInput( + appId: string, + formToken: string, + action: string, + inputs: Record, + ): Promise { + await this.http.post( + `apps/${encodeURIComponent(appId)}/form/human_input/${encodeURIComponent(formToken)}`, + { json: { action, inputs }, timeout: 30_000 }, + ) + } + + async reconnectStream( + appId: string, + workflowRunId: string, + opts: StreamOptions = {}, + ): Promise> { + const url = `apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(workflowRunId)}/events` + const res = await this.http.get(url, { + searchParams: { + include_state_snapshot: opts.includeStateSnapshot === true ? 'true' : 'false', + continue_on_pause: 'false', + }, + headers: { Accept: 'text/event-stream' }, + retry: { limit: 0 }, + timeout: false, + signal: opts.signal, + }) + 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.ts b/cli/src/api/apps.ts new file mode 100644 index 0000000000..fee146c244 --- /dev/null +++ b/cli/src/api/apps.ts @@ -0,0 +1,41 @@ +import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' + +export type ListQuery = { + readonly workspaceId: string + readonly page?: number + readonly limit?: number + readonly mode?: string + readonly name?: string + readonly tag?: string +} + +export class AppsClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async list(q: ListQuery): Promise { + const params = new URLSearchParams() + params.set('workspace_id', q.workspaceId) + params.set('page', String(q.page ?? 1)) + params.set('limit', String(q.limit ?? 20)) + if (q.mode !== undefined && q.mode !== '') + params.set('mode', q.mode) + if (q.name !== undefined && q.name !== '') + params.set('name', q.name) + if (q.tag !== undefined && q.tag !== '') + params.set('tag', q.tag) + return this.http.get('apps', { searchParams: params }).json() + } + + async describe(appId: string, workspaceId: string, fields?: readonly string[]): Promise { + const params = new URLSearchParams() + params.set('workspace_id', workspaceId) + if (fields !== undefined && fields.length > 0) + params.set('fields', fields.join(',')) + return this.http.get(`apps/${encodeURIComponent(appId)}/describe`, { searchParams: params }).json() + } +} diff --git a/cli/src/api/device-flow.test.ts b/cli/src/api/device-flow.test.ts new file mode 100644 index 0000000000..e850add937 --- /dev/null +++ b/cli/src/api/device-flow.test.ts @@ -0,0 +1,215 @@ +import type { AddressInfo } from 'node:net' +import type { DifyMock } from '../../test/fixtures/dify-mock/server.js' +import type { CodeResponse } from './oauth-device.js' +import { Buffer } from 'node:buffer' +import * as http from 'node:http' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { isBaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { createClient } from '../http/client.js' +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(createClient({ host: 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(createClient({ host: `${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(createClient({ host: 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(createClient({ host: 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(createClient({ host: 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(createClient({ host: 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(createClient({ host: 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.ts b/cli/src/api/file-upload.ts new file mode 100644 index 0000000000..bf1cdb1201 --- /dev/null +++ b/cli/src/api/file-upload.ts @@ -0,0 +1,72 @@ +import type { KyInstance } from 'ky' +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: KyInstance + + constructor(http: KyInstance) { + 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/upload`, + { body: form, timeout: 60_000 }, + ).json() + } +} diff --git a/cli/src/api/meta.test.ts b/cli/src/api/meta.test.ts new file mode 100644 index 0000000000..e41189054e --- /dev/null +++ b/cli/src/api/meta.test.ts @@ -0,0 +1,49 @@ +import type { DifyMock } from '../../test/fixtures/dify-mock/server.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { createClient } from '../http/client.js' +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(createClient({ host: 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(createClient({ host: 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(createClient({ host: mock.url })) + const info = await client.serverVersion() + + expect(info.version).toBe('') + expect(info.edition).toBe('SELF_HOSTED') + }) + + it('throws when the host has no Dify on it', async () => { + // Closed port — connection refused. + const client = new MetaClient(createClient({ host: '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 new file mode 100644 index 0000000000..1ddfdc4461 --- /dev/null +++ b/cli/src/api/meta.ts @@ -0,0 +1,19 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' + +// Used by every /_version probe call site (the version command and the +// per-command auto-nudge). Both must construct their ky client with this +// timeout + retry=0, otherwise the default 30s/3-retry budget kicks in. +export const META_PROBE_TIMEOUT_MS = 2000 + +export class MetaClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async serverVersion(): Promise { + return this.http.get('_version').json() + } +} diff --git a/cli/src/api/oauth-device.ts b/cli/src/api/oauth-device.ts new file mode 100644 index 0000000000..1368a7617b --- /dev/null +++ b/cli/src/api/oauth-device.ts @@ -0,0 +1,142 @@ +import type { KyInstance } from 'ky' +import { BaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' + +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 + 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: KyInstance + + constructor(http: KyInstance) { + 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.post('oauth/device/code', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) + if (res.status === 404) + throw versionSkew() + if (!res.ok) { + throw new BaseError({ + 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.post('oauth/device/token', { json: body, throwHttpErrors: false, context: { skipClassify: true } }) + 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(): BaseError { + return new BaseError({ + code: ErrorCode.UnsupportedEndpoint, + message: 'this Dify host does not implement the OAuth device flow', + httpStatus: 404, + }) +} diff --git a/cli/src/api/workspaces.ts b/cli/src/api/workspaces.ts new file mode 100644 index 0000000000..a3feac23d0 --- /dev/null +++ b/cli/src/api/workspaces.ts @@ -0,0 +1,14 @@ +import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' + +export class WorkspacesClient { + private readonly http: KyInstance + + constructor(http: KyInstance) { + this.http = http + } + + async list(): Promise { + return this.http.get('workspaces').json() + } +} diff --git a/cli/src/auth/file-backend.test.ts b/cli/src/auth/file-backend.test.ts new file mode 100644 index 0000000000..65ee66f6a9 --- /dev/null +++ b/cli/src/auth/file-backend.test.ts @@ -0,0 +1,101 @@ +import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { FILE_PERM } from '../config/dir.js' +import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js' + +describe('FileBackend', () => { + let dir: string + let backend: FileBackend + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-tokens-')) + backend = new FileBackend(dir) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('returns undefined when file is missing', async () => { + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) + + it('returns empty list when file is missing', async () => { + expect(await backend.list('cloud.dify.ai')).toEqual([]) + }) + + it('round-trips put/get for a single token', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_abc') + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_abc') + }) + + it('list returns accountIds for the given host', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + await backend.put('cloud.dify.ai', 'acct-2', 'dfoa_b') + await backend.put('self.example.com', 'acct-3', 'dfoa_c') + const ids = await backend.list('cloud.dify.ai') + expect([...ids].sort()).toEqual(['acct-1', 'acct-2']) + }) + + it('list returns empty array for unknown host', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + expect(await backend.list('other.example.com')).toEqual([]) + }) + + it('delete removes the entry', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + await backend.delete('cloud.dify.ai', 'acct-1') + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) + + it('delete is a no-op for missing entries', async () => { + await expect(backend.delete('cloud.dify.ai', 'missing')).resolves.toBeUndefined() + }) + + it('delete prunes empty host entries', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + await backend.delete('cloud.dify.ai', 'acct-1') + expect(await backend.list('cloud.dify.ai')).toEqual([]) + }) + + it('overwrites existing token for same host+accountId', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_old') + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_new') + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_new') + }) + + it('writes file with mode 0600', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + const info = await stat(join(dir, TOKENS_FILE_NAME)) + expect(info.mode & 0o777).toBe(FILE_PERM) + }) + + it('rewrites existing file with mode 0600 even if previously permissive', async () => { + const path = join(dir, TOKENS_FILE_NAME) + await writeFile(path, 'hosts: {}\n', { mode: 0o644 }) + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + const info = await stat(path) + expect(info.mode & 0o777).toBe(FILE_PERM) + }) + + it('writes valid YAML readable by a fresh backend', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + const fresh = new FileBackend(dir) + expect(await fresh.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a') + }) + + it('persists multiple hosts simultaneously', async () => { + await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a') + await backend.put('self.example.com', 'acct-2', 'dfoa_b') + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a') + expect(await backend.get('self.example.com', 'acct-2')).toBe('dfoa_b') + }) + + it('treats malformed YAML as empty', async () => { + const path = join(dir, TOKENS_FILE_NAME) + await writeFile(path, 'not: valid: yaml: [\n', { mode: FILE_PERM }) + expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) +}) diff --git a/cli/src/auth/file-backend.ts b/cli/src/auth/file-backend.ts new file mode 100644 index 0000000000..49bf4d44ed --- /dev/null +++ b/cli/src/auth/file-backend.ts @@ -0,0 +1,99 @@ +import type { TokenStore } from './store.js' +import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import yaml from 'js-yaml' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' + +export const TOKENS_FILE_NAME = 'tokens.yml' + +type AccountMap = Record +type HostMap = Record +type TokensFile = { hosts?: HostMap } + +export class FileBackend implements TokenStore { + private readonly dir: string + private readonly path: string + + constructor(dir: string) { + this.dir = dir + this.path = join(dir, TOKENS_FILE_NAME) + } + + async put(host: string, accountId: string, token: string): Promise { + const file = await this.read() + const hosts = file.hosts ?? {} + const accounts = hosts[host] ?? {} + accounts[accountId] = token + hosts[host] = accounts + await this.write({ hosts }) + } + + async get(host: string, accountId: string): Promise { + const file = await this.read() + return file.hosts?.[host]?.[accountId] + } + + async delete(host: string, accountId: string): Promise { + const file = await this.read() + const accounts = file.hosts?.[host] + if (accounts === undefined || !(accountId in accounts)) + return + delete accounts[accountId] + if (Object.keys(accounts).length === 0 && file.hosts !== undefined) + delete file.hosts[host] + await this.write(file) + } + + async list(host: string): Promise { + const file = await this.read() + const accounts = file.hosts?.[host] + return accounts === undefined ? [] : Object.keys(accounts) + } + + private async read(): Promise { + let raw: string + try { + raw = await readFile(this.path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return {} + throw err + } + let parsed: unknown + try { + parsed = yaml.load(raw) + } + catch { + return {} + } + if (parsed === null || typeof parsed !== 'object') + return {} + return parsed as TokensFile + } + + private async write(file: TokensFile): Promise { + await mkdir(this.dir, { recursive: true, mode: DIR_PERM }) + const body = yaml.dump(file, { lineWidth: -1, noRefs: true }) + const tmp = `${this.path}.tmp.${process.pid}.${Date.now()}` + try { + await writeFile(tmp, body, { mode: FILE_PERM }) + await rename(tmp, this.path) + } + catch (err) { + try { + await unlink(tmp) + } + catch { /* tmp may not exist */ } + throw err + } + try { + const info = await stat(this.path) + if ((info.mode & 0o777) !== FILE_PERM) { + const { chmod } = await import('node:fs/promises') + await chmod(this.path, FILE_PERM) + } + } + catch { /* best-effort permission tighten */ } + } +} diff --git a/cli/src/auth/hosts.test.ts b/cli/src/auth/hosts.test.ts new file mode 100644 index 0000000000..2bc1b2fea9 --- /dev/null +++ b/cli/src/auth/hosts.test.ts @@ -0,0 +1,131 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { FILE_PERM } from '../config/dir.js' +import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js' + +describe('HostsBundleSchema', () => { + it('parses a minimal logged-out bundle', () => { + const parsed = HostsBundleSchema.parse({}) + expect(parsed.current_host).toBe('') + expect(parsed.token_storage).toBe('file') + }) + + it('parses a logged-in keychain bundle', () => { + const parsed = HostsBundleSchema.parse({ + current_host: 'cloud.dify.ai', + account: { id: 'acct-1', email: 'a@b.c', name: 'A' }, + workspace: { id: 'ws-1', name: 'My Space', role: 'owner' }, + token_storage: 'keychain', + token_id: 'tok_xyz', + }) + expect(parsed.token_storage).toBe('keychain') + expect(parsed.tokens).toBeUndefined() + }) + + it('parses a logged-in file bundle with bearer', () => { + const parsed = HostsBundleSchema.parse({ + current_host: 'cloud.dify.ai', + token_storage: 'file', + tokens: { bearer: 'dfoa_xxx' }, + }) + expect(parsed.tokens?.bearer).toBe('dfoa_xxx') + }) + + it('rejects unknown token_storage values', () => { + expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow() + }) + + it('keeps available_workspaces when provided', () => { + const parsed = HostsBundleSchema.parse({ + available_workspaces: [ + { id: 'a', name: 'A', role: 'owner' }, + { id: 'b', name: 'B', role: 'member' }, + ], + }) + expect(parsed.available_workspaces).toHaveLength(2) + }) +}) + +describe('loadHosts/saveHosts', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('returns undefined when file is missing', async () => { + expect(await loadHosts(dir)).toBeUndefined() + }) + + it('round-trips bundle through YAML', async () => { + await saveHosts(dir, { + current_host: 'cloud.dify.ai', + account: { id: 'acct-1', email: 'a@b.c', name: 'A' }, + workspace: { id: 'ws-1', name: 'My Space', role: 'owner' }, + token_storage: 'keychain', + token_id: 'tok_xyz', + }) + const loaded = await loadHosts(dir) + expect(loaded?.current_host).toBe('cloud.dify.ai') + expect(loaded?.account?.email).toBe('a@b.c') + expect(loaded?.token_storage).toBe('keychain') + }) + + it('writes file with mode 0600', async () => { + await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) + const info = await stat(join(dir, HOSTS_FILE_NAME)) + expect(info.mode & 0o777).toBe(FILE_PERM) + }) + + it('rewrites permissive existing file with mode 0600', async () => { + const path = join(dir, HOSTS_FILE_NAME) + await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 }) + await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) + const info = await stat(path) + expect(info.mode & 0o777).toBe(FILE_PERM) + }) + + it('atomic write: temp file does not survive on success', async () => { + await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' }) + const { readdir } = await import('node:fs/promises') + const entries = await readdir(dir) + expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0) + }) + + it('drops unknown top-level fields', async () => { + const path = join(dir, HOSTS_FILE_NAME) + await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM }) + const loaded = await loadHosts(dir) + expect(loaded?.current_host).toBe('cloud.dify.ai') + expect((loaded as Record | undefined)?.future_field).toBeUndefined() + }) + + it('throws on malformed YAML', async () => { + const path = join(dir, HOSTS_FILE_NAME) + await writeFile(path, ': : :\n', { mode: FILE_PERM }) + await expect(loadHosts(dir)).rejects.toThrow() + }) + + it('throws when YAML contradicts schema', async () => { + const path = join(dir, HOSTS_FILE_NAME) + await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM }) + await expect(loadHosts(dir)).rejects.toThrow() + }) + + it('produces YAML with stable keys', async () => { + await saveHosts(dir, { + current_host: 'cloud.dify.ai', + token_storage: 'file', + tokens: { bearer: 'dfoa_x' }, + }) + const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8') + expect(raw).toContain('current_host: cloud.dify.ai') + expect(raw).toContain('bearer: dfoa_x') + }) +}) diff --git a/cli/src/auth/hosts.ts b/cli/src/auth/hosts.ts new file mode 100644 index 0000000000..fc90b3238c --- /dev/null +++ b/cli/src/auth/hosts.ts @@ -0,0 +1,100 @@ +import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import yaml from 'js-yaml' +import { z } from 'zod' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' + +export const HOSTS_FILE_NAME = 'hosts.yml' + +const StorageModeSchema = z.enum(['keychain', 'file']) +export type StorageMode = z.infer + +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 TokensSchema = z.object({ + bearer: z.string(), +}) +export type Tokens = z.infer + +export const HostsBundleSchema = z.object({ + current_host: z.string().default(''), + scheme: z.string().optional(), + account: AccountSchema.optional(), + workspace: WorkspaceSchema.optional(), + available_workspaces: z.array(WorkspaceSchema).optional(), + token_storage: StorageModeSchema.default('file'), + token_id: z.string().optional(), + token_expires_at: z.string().optional(), + tokens: TokensSchema.optional(), + external_subject: ExternalSubjectSchema.optional(), +}) +export type HostsBundle = z.infer + +export async function loadHosts(dir: string): Promise { + const path = join(dir, HOSTS_FILE_NAME) + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return undefined + throw err + } + const parsed = yaml.load(raw) + return HostsBundleSchema.parse(parsed ?? {}) +} + +export async function saveHosts(dir: string, bundle: HostsBundle): Promise { + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + const validated = HostsBundleSchema.parse(bundle) + const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false }) + const target = join(dir, HOSTS_FILE_NAME) + const tmp = `${target}.tmp.${process.pid}.${Date.now()}` + try { + await writeFile(tmp, body, { mode: FILE_PERM }) + await rename(tmp, target) + } + catch (err) { + try { + await unlink(tmp) + } + catch { /* tmp may not exist */ } + throw err + } + const { chmod, stat } = await import('node:fs/promises') + try { + const info = await stat(target) + if ((info.mode & 0o777) !== FILE_PERM) + await chmod(target, FILE_PERM) + } + catch { /* best-effort */ } +} + +function stripUndefined>(input: T): Record { + const out: Record = {} + for (const [k, v] of Object.entries(input)) { + if (v === undefined) + continue + out[k] = v + } + return out +} diff --git a/cli/src/auth/keyring-backend.test.ts b/cli/src/auth/keyring-backend.test.ts new file mode 100644 index 0000000000..19e0153916 --- /dev/null +++ b/cli/src/auth/keyring-backend.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const passwords = new Map() +const setPassword = vi.fn() +const getPassword = vi.fn() +const deletePassword = vi.fn() + +class FakeAsyncEntry { + private readonly key: string + constructor(service: string, username: string) { + this.key = `${service}::${username}` + } + + async setPassword(value: string): Promise { + setPassword(this.key, value) + passwords.set(this.key, value) + } + + async getPassword(): Promise { + getPassword(this.key) + return passwords.get(this.key) + } + + async deletePassword(): Promise { + deletePassword(this.key) + if (!passwords.has(this.key)) + return false + passwords.delete(this.key) + return true + } +} + +vi.mock('@napi-rs/keyring', () => ({ + AsyncEntry: FakeAsyncEntry, +})) + +const { KEYRING_SERVICE, KeyringBackend } = await import('./keyring-backend.js') + +beforeEach(() => { + passwords.clear() + setPassword.mockClear() + getPassword.mockClear() + deletePassword.mockClear() +}) + +describe('KeyringBackend', () => { + it('uses service name "difyctl"', () => { + expect(KEYRING_SERVICE).toBe('difyctl') + }) + + it('returns undefined when no password is stored', async () => { + const k = new KeyringBackend() + expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) + + it('round-trips put/get', async () => { + const k = new KeyringBackend() + await k.put('cloud.dify.ai', 'acct-1', 'dfoa_x') + expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_x') + }) + + it('keys by host::accountId', async () => { + const k = new KeyringBackend() + await k.put('cloud.dify.ai', 'acct-1', 'A') + await k.put('cloud.dify.ai', 'acct-2', 'B') + expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('A') + expect(await k.get('cloud.dify.ai', 'acct-2')).toBe('B') + }) + + it('delete removes the entry', async () => { + const k = new KeyringBackend() + await k.put('cloud.dify.ai', 'acct-1', 'A') + await k.delete('cloud.dify.ai', 'acct-1') + expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) + + it('delete is a no-op for missing entries', async () => { + const k = new KeyringBackend() + await expect(k.delete('cloud.dify.ai', 'gone')).resolves.toBeUndefined() + }) + + it('list returns empty array (keyring does not enumerate)', async () => { + const k = new KeyringBackend() + await k.put('cloud.dify.ai', 'acct-1', 'A') + expect(await k.list('cloud.dify.ai')).toEqual([]) + }) + + it('swallows getPassword exceptions and returns undefined', async () => { + const k = new KeyringBackend() + getPassword.mockImplementationOnce(() => { + throw new Error('NoEntry') + }) + expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined() + }) + + it('swallows delete exceptions', async () => { + const k = new KeyringBackend() + deletePassword.mockImplementationOnce(() => { + throw new Error('NoEntry') + }) + await expect(k.delete('cloud.dify.ai', 'acct-1')).resolves.toBeUndefined() + }) + + it('lets put propagate exceptions (caller decides fallback)', async () => { + const k = new KeyringBackend() + setPassword.mockImplementationOnce(() => { + throw new Error('keyring locked') + }) + await expect(k.put('cloud.dify.ai', 'acct-1', 'tok')).rejects.toThrow(/keyring locked/) + }) +}) diff --git a/cli/src/auth/keyring-backend.ts b/cli/src/auth/keyring-backend.ts new file mode 100644 index 0000000000..8e3dc75ab2 --- /dev/null +++ b/cli/src/auth/keyring-backend.ts @@ -0,0 +1,35 @@ +import type { TokenStore } from './store.js' +import { AsyncEntry } from '@napi-rs/keyring' + +export const KEYRING_SERVICE = 'difyctl' + +function username(host: string, accountId: string): string { + return `${host}::${accountId}` +} + +export class KeyringBackend implements TokenStore { + async put(host: string, accountId: string, token: string): Promise { + await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token) + } + + async get(host: string, accountId: string): Promise { + try { + const v = await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).getPassword() + return v ?? undefined + } + catch { + return undefined + } + } + + async delete(host: string, accountId: string): Promise { + try { + await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword() + } + catch { /* missing entry is fine */ } + } + + async list(_host: string): Promise { + return [] + } +} diff --git a/cli/src/auth/store.test.ts b/cli/src/auth/store.test.ts new file mode 100644 index 0000000000..21498ae9c0 --- /dev/null +++ b/cli/src/auth/store.test.ts @@ -0,0 +1,75 @@ +import type { TokenStore } from './store.js' +import { describe, expect, it, vi } from 'vitest' +import { selectStore } from './store.js' + +function memBackend(label: string): TokenStore & { _label: string } { + const map = new Map() + const k = (h: string, a: string) => `${h}::${a}` + return { + _label: label, + async put(h, a, t) { map.set(k(h, a), t) }, + async get(h, a) { return map.get(k(h, a)) }, + async delete(h, a) { map.delete(k(h, a)) }, + async list() { return [] }, + } +} + +describe('selectStore', () => { + it('returns keychain when probe succeeds', async () => { + const k = memBackend('keyring') + const f = memBackend('file') + const result = await selectStore({ + configDir: '/tmp/x', + factory: { keyring: () => k, file: () => f }, + }) + expect(result.mode).toBe('keychain') + expect(result.store).toBe(k) + }) + + it('falls back to file when keyring put throws', async () => { + const k = memBackend('keyring') + const f = memBackend('file') + k.put = vi.fn().mockRejectedValue(new Error('locked')) + const result = await selectStore({ + configDir: '/tmp/x', + factory: { keyring: () => k, file: () => f }, + }) + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + + it('falls back to file when probe round-trip mismatches', async () => { + const k = memBackend('keyring') + const f = memBackend('file') + k.get = vi.fn().mockResolvedValue('something-else') + const result = await selectStore({ + configDir: '/tmp/x', + factory: { keyring: () => k, file: () => f }, + }) + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + + it('falls back to file when keyring constructor throws', async () => { + const f = memBackend('file') + const result = await selectStore({ + configDir: '/tmp/x', + factory: { + keyring: () => { throw new Error('no backend') }, + file: () => f, + }, + }) + expect(result.mode).toBe('file') + expect(result.store).toBe(f) + }) + + it('cleans up probe entry after successful probe', async () => { + const k = memBackend('keyring') + const f = memBackend('file') + await selectStore({ + configDir: '/tmp/x', + factory: { keyring: () => k, file: () => f }, + }) + expect(await k.get('__difyctl_probe__', '__probe__')).toBeUndefined() + }) +}) diff --git a/cli/src/auth/store.ts b/cli/src/auth/store.ts new file mode 100644 index 0000000000..1be2a0606c --- /dev/null +++ b/cli/src/auth/store.ts @@ -0,0 +1,40 @@ +import { FileBackend } from './file-backend.js' +import { KeyringBackend } from './keyring-backend.js' + +export type TokenStore = { + put: (host: string, accountId: string, token: string) => Promise + get: (host: string, accountId: string) => Promise + delete: (host: string, accountId: string) => Promise + list: (host: string) => Promise +} + +export type StorageMode = 'keychain' | 'file' + +export type SelectStoreOptions = { + readonly configDir: string + readonly factory?: { + readonly keyring?: () => TokenStore + readonly file?: (dir: string) => TokenStore + } +} + +const PROBE_HOST = '__difyctl_probe__' +const PROBE_ACCOUNT = '__probe__' +const PROBE_VALUE = 'probe-v1' + +export async function selectStore(opts: SelectStoreOptions): Promise<{ store: TokenStore, mode: StorageMode }> { + const fileFactory = opts.factory?.file ?? ((dir: string) => new FileBackend(dir)) + const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBackend()) + try { + const k = keyringFactory() + await k.put(PROBE_HOST, PROBE_ACCOUNT, PROBE_VALUE) + const got = await k.get(PROBE_HOST, PROBE_ACCOUNT) + await k.delete(PROBE_HOST, PROBE_ACCOUNT) + if (got !== PROBE_VALUE) + throw new Error('keyring round-trip mismatch') + return { store: k, mode: 'keychain' } + } + catch { + return { store: fileFactory(opts.configDir), mode: 'file' } + } +} diff --git a/cli/src/cache/app-info.test.ts b/cli/src/cache/app-info.test.ts new file mode 100644 index 0000000000..c562519790 --- /dev/null +++ b/cli/src/cache/app-info.test.ts @@ -0,0 +1,111 @@ +import type { AppMeta } from '../types/app-meta.js' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { FieldInfo, FieldParameters } from '../types/app-meta.js' +import { APP_INFO_TTL_MS, cachePath, loadAppInfoCache } from './app-info.js' + +function metaInfoOnly(): AppMeta { + return { + info: { + id: 'app-1', + name: 'Greeter', + description: '', + mode: 'chat', + author: 'tester', + tags: [], + 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 + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-')) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('round-trips an entry across reloads', async () => { + const c1 = await loadAppInfoCache({ configDir: dir }) + await c1.set('http://localhost:9999', 'app-1', metaInfoOnly()) + + const c2 = await loadAppInfoCache({ configDir: dir }) + 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({ configDir: dir, 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({ configDir: dir }) + 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({ configDir: dir }) + await c1.set('h', 'app-1', metaInfoOnly()) + await c1.delete('h', 'app-1') + + const c2 = await loadAppInfoCache({ configDir: dir }) + expect(c2.get('h', 'app-1')).toBeUndefined() + }) + + it('writes file with 0600 permission', async () => { + const c = await loadAppInfoCache({ configDir: dir }) + await c.set('h', 'app-1', metaInfoOnly()) + const { stat } = await import('node:fs/promises') + const s = await stat(cachePath(dir)) + if (process.platform !== 'win32') + expect(s.mode & 0o777).toBe(0o600) + }) + + it('missing cache file is not an error', async () => { + const c = await loadAppInfoCache({ configDir: dir }) + expect(c.get('h', 'app-1')).toBeUndefined() + }) + + it('corrupt cache file is treated as empty', async () => { + const { mkdir, writeFile } = await import('node:fs/promises') + await mkdir(join(dir, 'cache'), { recursive: true }) + await writeFile(cachePath(dir), '{not json', 'utf8') + const c = await loadAppInfoCache({ configDir: dir }) + expect(c.get('h', 'app-1')).toBeUndefined() + }) + + it('updates same key in place (no growth)', async () => { + const c = await loadAppInfoCache({ configDir: dir }) + 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(cachePath(dir), 'utf8') + const parsed = JSON.parse(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 new file mode 100644 index 0000000000..e6aef5a168 --- /dev/null +++ b/cli/src/cache/app-info.ts @@ -0,0 +1,138 @@ +import type { AppMeta, AppMetaCacheRecord, AppMetaFieldKey } from '../types/app-meta.js' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' +import { FieldInfo, FieldInputSchema, FieldParameters } from '../types/app-meta.js' + +const CACHE_FILE = 'app-info.json' +export const APP_INFO_TTL_MS = 60 * 60 * 1000 + +type DiskShape = { + entries: Record +} + +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 configDir: string + readonly ttlMs?: number + readonly now?: () => Date +} + +export async function loadAppInfoCache(opts: AppInfoCacheOptions): Promise { + const path = cachePath(opts.configDir) + const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS + const state: State = { entries: new Map() } + await readDisk(path, state) + 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 persist(path, state) + }, + delete: async (host, appId) => { + state.entries.delete(key(host, appId)) + await persist(path, state) + }, + isFresh: (record, now) => { + const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime() + return t >= 0 && t < ttlMs + }, + } +} + +export function cachePath(configDir: string): string { + return join(configDir, 'cache', CACHE_FILE) +} + +function key(host: string, appId: string): string { + return `${host}::${appId}` +} + +async function readDisk(path: string, state: State): Promise { + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return + throw err + } + let parsed: DiskShape + try { + parsed = JSON.parse(raw) as DiskShape + } + catch { + return + } + if (parsed.entries === undefined) + return + for (const [k, e] of Object.entries(parsed.entries)) { + state.entries.set(k, deserialize(e)) + } +} + +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 persist(path: string, state: State): Promise { + const dir = dirname(path) + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + const disk: DiskShape = { entries: {} } + for (const [k, v] of state.entries) disk.entries[k] = serialize(v) + const tmp = `${path}.${process.pid}.${Date.now()}.tmp` + await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM }) + await rename(tmp, path) +} diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts new file mode 100644 index 0000000000..90068a1821 --- /dev/null +++ b/cli/src/cache/nudge-store.test.ts @@ -0,0 +1,94 @@ +import { 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 'vitest' +import { loadNudgeStore, nudgeStorePath, WARN_INTERVAL_MS } from './nudge-store.js' + +const HOST = 'https://cloud.dify.ai' + +describe('NudgeStore', () => { + let dir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('canWarn=true when no prior record exists', async () => { + const store = await loadNudgeStore({ configDir: dir }) + 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({ configDir: dir, 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({ configDir: dir, 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({ configDir: dir, now: () => t0 }) + await s1.markWarned(HOST) + const s2 = await loadNudgeStore({ configDir: dir, 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({ configDir: dir }) + expect(store.canWarn(HOST)).toBe(true) + }) + + it('ignores file with mismatched schema', async () => { + const path = nudgeStorePath(dir) + await writeCacheFile(path, JSON.stringify({ schema: 99, warned: { [HOST]: '2026-05-19T12:00:00.000Z' } })) + const store = await loadNudgeStore({ configDir: dir }) + expect(store.canWarn(HOST)).toBe(true) + }) + + it('writes ISO timestamps under schema:1/warned on disk', async () => { + const t = new Date('2026-05-19T12:00:00.000Z') + const store = await loadNudgeStore({ configDir: dir, now: () => t }) + await store.markWarned(HOST) + const raw = await readFile(nudgeStorePath(dir), 'utf8') + const parsed = JSON.parse(raw) as Record + expect(parsed.schema).toBe(1) + 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({ configDir: dir, now: () => t }) + const b = await loadNudgeStore({ configDir: dir, now: () => t }) + await a.markWarned('https://a.example') + await b.markWarned('https://b.example') + const reread = await loadNudgeStore({ configDir: dir, 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 new file mode 100644 index 0000000000..2a0d0ab994 --- /dev/null +++ b/cli/src/cache/nudge-store.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' + +const CACHE_FILE = 'nudge.json' +const DISK_SCHEMA = 1 +export const WARN_INTERVAL_MS = 24 * 60 * 60 * 1000 + +export type NudgeStore = { + readonly canWarn: (host: string, now?: Date) => boolean + readonly markWarned: (host: string, now?: Date) => Promise +} + +export type NudgeStoreOptions = { + readonly configDir: string + readonly now?: () => Date + readonly intervalMs?: number +} + +type DiskShape = { + schema?: number + warned?: Record +} + +export function nudgeStorePath(configDir: string): string { + return join(configDir, 'cache', CACHE_FILE) +} + +export async function loadNudgeStore(opts: NudgeStoreOptions): Promise { + const path = nudgeStorePath(opts.configDir) + const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS + const clock = opts.now ?? (() => new Date()) + const memory = await readDisk(path) + + 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 readDisk(path) + onDisk.set(host, stamp) + await persist(path, onDisk) + }, + } +} + +async function readDisk(path: string): Promise> { + const out = new Map() + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return out + throw err + } + let parsed: DiskShape + try { + parsed = JSON.parse(raw) as DiskShape + } + catch { + return out + } + if (parsed.schema !== DISK_SCHEMA || parsed.warned === undefined) + return out + for (const [host, iso] of Object.entries(parsed.warned)) { + const t = Date.parse(iso) + if (!Number.isNaN(t)) + out.set(host, t) + } + return out +} + +async function persist(path: string, state: Map): Promise { + const dir = dirname(path) + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + const disk: DiskShape = { schema: DISK_SCHEMA, warned: {} } + for (const [host, t] of state) + disk.warned![host] = new Date(t).toISOString() + // randomUUID is collision-proof even when two writers stamp the same + // millisecond — pid+timestamp alone can still collide under tight loops. + const tmp = `${path}.${randomUUID()}.tmp` + await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM }) + await rename(tmp, path) +} diff --git a/cli/src/commands/AGENTS.md b/cli/src/commands/AGENTS.md new file mode 100644 index 0000000000..0a4a1ec3fa --- /dev/null +++ b/cli/src/commands/AGENTS.md @@ -0,0 +1,67 @@ +# AGENTS.md — `src/commands/` + +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. + +``` +src/commands/ + / + / + index.ts ← command class (extends DifyCommand; the ONLY file the registry discovers) + run.ts ← business logic (not a command, invisible to the registry) + handlers.ts ← helpers + guide.ts ← agent guide string (optional) + *.test.ts ← tests + / ← subcommand (e.g. auth/devices/list/) + index.ts + _shared/ ← intra-topic shared code (only when needed by 2+ siblings) + .ts +``` + +The registry generator (`pnpm tree:gen` → `src/commands/tree.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. + +## Adding a new command + +1. Create `src/commands///index.ts` extending `DifyCommand`. +1. Add business logic in sibling files (e.g. `run.ts`, `handlers.ts`). +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 { + static agentGuide = 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. diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts new file mode 100644 index 0000000000..97d8648f3f --- /dev/null +++ b/cli/src/commands/_shared/authed-command.ts @@ -0,0 +1,91 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../auth/hosts.js' +import type { AppInfoCache } from '../../cache/app-info.js' +import type { Command } from '../../framework/command.js' +import type { IOStreams } from '../../io/streams.js' +import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js' +import { loadHosts } from '../../auth/hosts.js' +import { loadAppInfoCache } from '../../cache/app-info.js' +import { loadNudgeStore } from '../../cache/nudge-store.js' +import { resolveConfigDir } from '../../config/dir.js' +import { BaseError } from '../../errors/base.js' +import { ErrorCode } from '../../errors/codes.js' +import { formatErrorForCli } from '../../errors/format.js' +import { createClient } from '../../http/client.js' +import { realStreams } from '../../io/streams.js' +import { hostWithScheme } from '../../util/host.js' +import { versionInfo } from '../../version/info.js' +import { maybeNudgeCompat } from '../../version/nudge.js' +import { resolveRetryAttempts } from './global-flags.js' + +export type AuthedContext = { + readonly bundle: HostsBundle + readonly http: KyInstance + readonly host: string + readonly io: IOStreams + readonly configDir: string + 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 configDir = resolveConfigDir() + const bundle = await loadHosts(configDir) + if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { + const err = new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'not logged in', + hint: 'run \'difyctl auth login\'', + }) + cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: process.stderr.isTTY }), { exit: err.exit() }) + } + + const host = hostWithScheme(bundle.current_host, bundle.scheme) + const retryAttempts = resolveRetryAttempts({ + flag: opts.retryFlag, + env: (k: string) => process.env[k], + }) + const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts }) + const io = realStreams(opts.format ?? '') + + const cache = opts.withCache === true ? await loadAppInfoCache({ configDir }) : undefined + + await runCompatNudge({ configDir, host, io }) + + return { bundle, http, host, io, configDir, cache } +} + +// 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 configDir: string + readonly host: string + readonly io: IOStreams +}): Promise { + try { + const store = await loadNudgeStore({ configDir: opts.configDir }) + await maybeNudgeCompat(opts.host, { + store, + probe: async (host) => { + const http = createClient({ host, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + 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 new file mode 100644 index 0000000000..80a25e377f --- /dev/null +++ b/cli/src/commands/_shared/dify-command.ts @@ -0,0 +1,9 @@ +import type { AuthedContext, AuthedContextOptions } from './authed-command.js' +import { Command } from '../../framework/command.js' +import { buildAuthedContext } from './authed-command.js' + +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 new file mode 100644 index 0000000000..2fe678726b --- /dev/null +++ b/cli/src/commands/_shared/global-flags.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { resolveRetryAttempts } from './global-flags.js' + +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 new file mode 100644 index 0000000000..20777c99f9 --- /dev/null +++ b/cli/src/commands/_shared/global-flags.ts @@ -0,0 +1,29 @@ +import { newError } from '../../errors/base.js' +import { ErrorCode } from '../../errors/codes.js' +import { Flags } from '../../framework/flags.js' + +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/auth/devices/_shared/devices.test.ts b/cli/src/commands/auth/devices/_shared/devices.test.ts new file mode 100644 index 0000000000..92e9bc8826 --- /dev/null +++ b/cli/src/commands/auth/devices/_shared/devices.test.ts @@ -0,0 +1,189 @@ +import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../../auth/hosts.js' +import type { TokenStore } from '../../../../auth/store.js' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../../test/fixtures/dify-mock/server.js' +import { saveHosts } from '../../../../auth/hosts.js' +import { createClient } from '../../../../http/client.js' +import { bufferStreams } from '../../../../io/streams.js' +import { runDevicesList, runDevicesRevoke } from './devices.js' + +class MemStore implements TokenStore { + readonly entries = new Map() + async put(host: string, accountId: string, token: string): Promise { + this.entries.set(`${host}::${accountId}`, token) + } + + async get(host: string, accountId: string): Promise { + return this.entries.get(`${host}::${accountId}`) + } + + async delete(host: string, accountId: string): Promise { + this.entries.delete(`${host}::${accountId}`) + } + + async list(host: string): Promise { + const prefix = `${host}::` + return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix)) + } +} + +function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle { + return { + current_host: host, + scheme: 'http', + token_storage: 'file', + token_id: tokenId, + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +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 = createClient({ host: mock.url, bearer: 'dfoa_test' }) + await runDevicesList({ io, bundle: bundleFor(mock.url, '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 = createClient({ host: mock.url, bearer: 'dfoa_test' }) + await runDevicesList({ io, bundle: bundleFor(mock.url), 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) + }) + + it('not-logged-in: throws NotLoggedIn', async () => { + const io = bufferStreams() + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + await expect(runDevicesList({ io, bundle: undefined, http })) + .rejects + .toThrow(/not logged in/) + }) +}) + +describe('runDevicesRevoke', () => { + let mock: DifyMock + let configDir: string + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-')) + }) + afterEach(async () => { + await mock.stop() + await rm(configDir, { recursive: true, force: true }) + }) + + it('exact device_label: revokes one + leaves local creds', async () => { + const io = bufferStreams() + const store = new MemStore() + const b = bundleFor(mock.url, 'tok-1') + await store.put(b.current_host, 'acct-1', 'dfoa_test') + await saveHosts(configDir, b) + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runDevicesRevoke({ configDir, io, bundle: b, http, store, 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 b = bundleFor(mock.url, 'tok-1') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runDevicesRevoke({ configDir, io, bundle: b, http, store, 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 b = bundleFor(mock.url, 'tok-1') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runDevicesRevoke({ configDir, io, bundle: b, http, store, 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 b = bundleFor(mock.url, 'tok-1') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false })) + .rejects + .toThrow(/matches multiple/) + }) + + it('no match throws', async () => { + const io = bufferStreams() + const store = new MemStore() + const b = bundleFor(mock.url, 'tok-1') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false })) + .rejects + .toThrow(/no session matches/) + }) + + it('--all: revokes everything except current', async () => { + const io = bufferStreams() + const store = new MemStore() + const b = bundleFor(mock.url, 'tok-1') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true }) + expect(io.outBuf()).toContain('Revoked 2 session(s)') + }) + + it('revoking current id clears local creds', async () => { + const io = bufferStreams() + const store = new MemStore() + const b = bundleFor(mock.url, 'tok-1') + await store.put(b.current_host, 'acct-1', 'dfoa_test') + await saveHosts(configDir, b) + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false }) + expect(store.entries.size).toBe(0) + await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) + }) + + it('no target + no --all: throws UsageMissingArg', async () => { + const io = bufferStreams() + const store = new MemStore() + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false })) + .rejects + .toThrow(/specify a device label/) + }) +}) diff --git a/cli/src/commands/auth/devices/_shared/devices.ts b/cli/src/commands/auth/devices/_shared/devices.ts new file mode 100644 index 0000000000..5af41cd4db --- /dev/null +++ b/cli/src/commands/auth/devices/_shared/devices.ts @@ -0,0 +1,159 @@ +import type { SessionRow } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../../auth/hosts.js' +import type { TokenStore } from '../../../../auth/store.js' +import type { IOStreams } from '../../../../io/streams.js' +import { unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { AccountSessionsClient } from '../../../../api/account-sessions.js' +import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js' +import { BaseError } from '../../../../errors/base.js' +import { ErrorCode } from '../../../../errors/codes.js' +import { colorEnabled, colorScheme } from '../../../../io/color.js' +import { runWithSpinner } from '../../../../io/spinner.js' + +export type DevicesListOptions = { + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly http: KyInstance + readonly json?: boolean +} + +export async function runDevicesList(opts: DevicesListOptions): Promise { + const b = requireLogin(opts.bundle) + const sessions = new AccountSessionsClient(opts.http) + const env = await runWithSpinner( + { io: opts.io, label: 'Fetching devices' }, + () => sessions.list(), + ) + + if (opts.json === true) { + opts.io.out.write(`${JSON.stringify(env)}\n`) + return + } + + opts.io.out.write(renderTable(env.data, b.token_id ?? '')) +} + +export type DevicesRevokeOptions = { + readonly configDir: string + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly http: KyInstance + readonly store: TokenStore + readonly target?: string + readonly all: boolean + readonly yes?: boolean +} + +export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise { + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const b = requireLogin(opts.bundle) + 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 env = await sessions.list() + const { ids, selfHit } = pickTargets(env.data, opts, b.token_id ?? '') + if (ids.length === 0) { + opts.io.out.write('no sessions to revoke\n') + return + } + + for (const id of ids) + await sessions.revoke(id) + + if (selfHit) + await clearLocal(opts.configDir, b, opts.store) + + opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`) +} + +function requireLogin(b: HostsBundle | undefined): HostsBundle { + if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'not logged in', + hint: 'run \'difyctl auth login\'', + }) + } + return b +} + +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` +} + +async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise { + const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' + try { + await store.delete(bundle.current_host, accountId) + } + catch { /* best-effort */ } + try { + await unlink(join(configDir, HOSTS_FILE_NAME)) + } + catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') + throw err + } +} diff --git a/cli/src/commands/auth/devices/list/index.ts b/cli/src/commands/auth/devices/list/index.ts new file mode 100644 index 0000000000..2a52ff24bb --- /dev/null +++ b/cli/src/commands/auth/devices/list/index.ts @@ -0,0 +1,25 @@ +import { Flags } from '../../../../framework/flags.js' +import { DifyCommand } from '../../../_shared/dify-command.js' +import { httpRetryFlag } from '../../../_shared/global-flags.js' +import { runDevicesList } from '../_shared/devices.js' + +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', + ] + + static override flags = { + 'http-retry': httpRetryFlag, + 'json': Flags.boolean({ description: 'emit JSON', default: false }), + } + + 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, bundle: ctx.bundle, http: ctx.http, json: flags.json }) + } +} diff --git a/cli/src/commands/auth/devices/revoke/index.ts b/cli/src/commands/auth/devices/revoke/index.ts new file mode 100644 index 0000000000..1d4f1b0db4 --- /dev/null +++ b/cli/src/commands/auth/devices/revoke/index.ts @@ -0,0 +1,40 @@ +import { selectStore } from '../../../../auth/store.js' +import { Args, Flags } from '../../../../framework/flags.js' +import { DifyCommand } from '../../../_shared/dify-command.js' +import { httpRetryFlag } from '../../../_shared/global-flags.js' +import { runDevicesRevoke } from '../_shared/devices.js' + +export default class DevicesRevoke extends DifyCommand { + static override description = 'Revoke one or all session devices' + + 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({ 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'] }) + const { store } = await selectStore({ configDir: ctx.configDir }) + await runDevicesRevoke({ + configDir: ctx.configDir, + io: ctx.io, + bundle: ctx.bundle, + http: ctx.http, + store, + target: args.target, + all: flags.all, + yes: flags.yes, + }) + } +} diff --git a/cli/src/commands/auth/login/device-flow.test.ts b/cli/src/commands/auth/login/device-flow.test.ts new file mode 100644 index 0000000000..4a27788329 --- /dev/null +++ b/cli/src/commands/auth/login/device-flow.test.ts @@ -0,0 +1,173 @@ +import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '../../../api/oauth-device.js' +import type { Clock } from './device-flow.js' +import { describe, expect, it, vi } from 'vitest' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { + awaitAuthorization, + DEFAULT_INTERVAL_MS, + MAX_INTERVAL_MS, + POLL_RETRY_ATTEMPTS, + POLL_RETRY_CAP_MS, + POLL_RETRY_INITIAL_MS, +} from './device-flow.js' + +const successPayload: PollSuccess = { + token: 'dfoa_xyz', + account: { id: 'a', email: 'e', name: 'n' }, + workspaces: [{ id: 'w', name: 'W', role: 'owner' }], + default_workspace_id: 'w', + token_id: 't', +} + +class FakeClock implements Clock { + sleeps: number[] = [] + cancelled = false + cancelAt: number | undefined + + async sleepMs(ms: number): Promise { + this.sleeps.push(ms) + if (this.cancelAt !== undefined && this.sleeps.length >= this.cancelAt) + this.cancelled = true + } + + isCancelled(): boolean { + return this.cancelled + } +} + +function fakeApi(scripted: PollResult[]): { pollOnce: (req: PollRequest) => Promise } { + let i = 0 + return { + pollOnce: async () => { + const r = scripted[i++] + if (r === undefined) + throw new Error('scripted-api: out of responses') + return r + }, + } +} + +const code: CodeResponse = { + device_code: 'dc', + user_code: 'ABCD-1234', + verification_uri: 'https://dify.example/device', + expires_in: 900, + interval: 1, +} + +describe('awaitAuthorization', () => { + it('returns success on first approved poll', async () => { + const api = fakeApi([{ status: 'approved', success: successPayload }]) + const clock = new FakeClock() + const result = await awaitAuthorization(api, code, { clock }) + expect(result.token).toBe('dfoa_xyz') + expect(clock.sleeps).toHaveLength(0) + }) + + it('keeps polling on pending then returns approved', async () => { + const api = fakeApi([ + { status: 'pending' }, + { status: 'pending' }, + { status: 'approved', success: successPayload }, + ]) + const clock = new FakeClock() + const result = await awaitAuthorization(api, code, { clock }) + expect(result.token).toBe('dfoa_xyz') + expect(clock.sleeps).toEqual([1000, 1000]) + }) + + it('doubles interval on slow_down (capped at max)', async () => { + const api = fakeApi([ + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'approved', success: successPayload }, + ]) + const clock = new FakeClock() + const result = await awaitAuthorization(api, code, { clock }) + expect(result.token).toBe('dfoa_xyz') + expect(clock.sleeps).toEqual([2000, 4000]) + }) + + it('caps interval at MAX_INTERVAL_MS', async () => { + const api = fakeApi([ + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'slow_down' }, + { status: 'approved', success: successPayload }, + ]) + const clock = new FakeClock() + await awaitAuthorization(api, { ...code, interval: 10 }, { clock }) + const last = clock.sleeps[clock.sleeps.length - 1]! + expect(last).toBe(MAX_INTERVAL_MS) + }) + + it('throws BaseError on expired', async () => { + const api = fakeApi([{ status: 'expired' }]) + const clock = new FakeClock() + await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/expired/) + }) + + it('throws BaseError on denied', async () => { + const api = fakeApi([{ status: 'denied' }]) + const clock = new FakeClock() + await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/denied/) + }) + + it('uses default interval when CodeResponse.interval is 0', async () => { + const api = fakeApi([ + { status: 'pending' }, + { status: 'approved', success: successPayload }, + ]) + const clock = new FakeClock() + await awaitAuthorization(api, { ...code, interval: 0 }, { clock }) + expect(clock.sleeps[0]).toBe(DEFAULT_INTERVAL_MS) + }) + + it('rejects when clock signals cancelled', async () => { + const api = fakeApi([ + { status: 'pending' }, + { status: 'pending' }, + { status: 'pending' }, + { status: 'pending' }, + { status: 'approved', success: successPayload }, + ]) + const clock = new FakeClock() + clock.cancelAt = 2 + await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/expired|cancel/) + }) + + it('exposes constants matching Go reference', () => { + expect(POLL_RETRY_ATTEMPTS).toBe(5) + expect(POLL_RETRY_INITIAL_MS).toBe(1000) + expect(POLL_RETRY_CAP_MS).toBe(16_000) + expect(MAX_INTERVAL_MS).toBe(60_000) + expect(DEFAULT_INTERVAL_MS).toBe(5000) + }) + + it('preserves dfoe_ token kind through state machine', async () => { + const externalSuccess: PollSuccess = { + token: 'dfoe_xxx', + subject_type: 'external_sso', + subject_email: 'sso@x.com', + subject_issuer: 'https://issuer', + } + const api = fakeApi([{ status: 'approved', success: externalSuccess }]) + const clock = new FakeClock() + const result = await awaitAuthorization(api, code, { clock }) + expect(result.token).toBe('dfoe_xxx') + expect(result.subject_type).toBe('external_sso') + }) + + it('propagates BaseError thrown by api.pollOnce', async () => { + const api = { + pollOnce: vi.fn().mockRejectedValue(new BaseError({ code: ErrorCode.UnsupportedEndpoint, 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/commands/auth/login/device-flow.ts new file mode 100644 index 0000000000..bf319dccec --- /dev/null +++ b/cli/src/commands/auth/login/device-flow.ts @@ -0,0 +1,107 @@ +import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '../../../api/oauth-device.js' +import { DEFAULT_CLIENT_ID } from '../../../api/oauth-device.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' + +export const DEFAULT_INTERVAL_MS = 5_000 +export const MAX_INTERVAL_MS = 60_000 +export const POLL_RETRY_ATTEMPTS = 5 +export const POLL_RETRY_INITIAL_MS = 1_000 +export const POLL_RETRY_CAP_MS = 16_000 + +export type Clock = { + sleepMs: (ms: number) => Promise + isCancelled: () => boolean +} + +export type DeviceFlowApiSubset = { + pollOnce: (req: PollRequest) => Promise +} + +export type AwaitOptions = { + clock: Clock + clientId?: string +} + +export async function awaitAuthorization( + api: DeviceFlowApiSubset, + code: CodeResponse, + opts: AwaitOptions, +): Promise { + if (code.device_code === '') + throw expired() + + 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, + } + + while (true) { + if (opts.clock.isCancelled()) + throw expired() + 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', + }) + } + await opts.clock.sleepMs(interval) + if (opts.clock.isCancelled()) + throw expired() + } +} + +async function pollWithRetry( + api: DeviceFlowApiSubset, + req: PollRequest, + clock: Clock, +): Promise { + let backoff = POLL_RETRY_INITIAL_MS + for (let attempt = 1; attempt <= POLL_RETRY_ATTEMPTS; attempt++) { + const result = await api.pollOnce(req) + if (result.status !== 'retry_5xx') + return result + if (attempt === POLL_RETRY_ATTEMPTS) + break + await clock.sleepMs(backoff) + backoff = Math.min(backoff * 2, POLL_RETRY_CAP_MS) + } + return { status: 'retry_5xx' } +} + +function expired(): BaseError { + return new BaseError({ + code: ErrorCode.ExpiredToken, + message: 'code expired before authorization', + }) +} + +export function realClock(): Clock { + const cancelled = false + return { + async sleepMs(ms) { + await new Promise(r => setTimeout(r, ms)) + }, + isCancelled() { + return cancelled + }, + } +} diff --git a/cli/src/commands/auth/login/index.ts b/cli/src/commands/auth/login/index.ts new file mode 100644 index 0000000000..ab8c32cd74 --- /dev/null +++ b/cli/src/commands/auth/login/index.ts @@ -0,0 +1,41 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { Flags } from '../../../framework/flags.js' +import { realStreams } from '../../../io/streams.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runLogin } from './login.js' + +export default class Login extends DifyCommand { + static override description = 'Sign in to Dify via OAuth device flow' + + 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 (local-dev only)', + default: false, + }), + } + + async run(argv: string[]): Promise { + const { flags } = this.parse(Login, argv) + await runLogin({ + configDir: resolveConfigDir(), + io: realStreams(), + host: flags.host, + noBrowser: flags['no-browser'], + insecure: flags.insecure, + }) + } +} diff --git a/cli/src/commands/auth/login/login.test.ts b/cli/src/commands/auth/login/login.test.ts new file mode 100644 index 0000000000..522623982b --- /dev/null +++ b/cli/src/commands/auth/login/login.test.ts @@ -0,0 +1,185 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { TokenStore } from '../../../auth/store.js' +import type { Clock } from './device-flow.js' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { DeviceFlowApi } from '../../../api/oauth-device.js' +import { createClient } from '../../../http/client.js' +import { bufferStreams } from '../../../io/streams.js' +import { runLogin } from './login.js' + +const noopClock: Clock = { + sleepMs: async () => { /* immediate */ }, + isCancelled: () => false, +} + +const noopBrowser = async (): Promise => { /* skip OS open */ } + +class MemStore implements TokenStore { + readonly entries = new Map() + async put(host: string, accountId: string, token: string): Promise { + this.entries.set(`${host}::${accountId}`, token) + } + + async get(host: string, accountId: string): Promise { + return this.entries.get(`${host}::${accountId}`) + } + + async delete(host: string, accountId: string): Promise { + this.entries.delete(`${host}::${accountId}`) + } + + async list(host: string): Promise { + const prefix = `${host}::` + return Array.from(this.entries.keys()) + .filter(k => k.startsWith(prefix)) + .map(k => k.slice(prefix.length)) + } +} + +describe('runLogin', () => { + let mock: DifyMock + let configDir: string + + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-')) + }) + + afterEach(async () => { + await mock.stop() + await rm(configDir, { recursive: true, force: true }) + }) + + it('happy: stores bearer + writes hosts.yml + greets account user', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = await runLogin({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: mock.url })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }) + expect(bundle.tokens?.bearer).toBe('dfoa_test') + expect(bundle.account?.email).toBe('tester@dify.ai') + expect(bundle.workspace?.id).toBe('ws-1') + expect(bundle.available_workspaces).toHaveLength(2) + const stored = await store.get(bundle.current_host, 'acct-1') + expect(stored).toBe('dfoa_test') + + const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8') + expect(hostsRaw).toContain('current_host:') + expect(hostsRaw).toContain('tester@dify.ai') + + 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 bundle = await runLogin({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: mock.url })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }) + expect(bundle.tokens?.bearer).toBe('dfoe_test') + expect(bundle.account).toBeUndefined() + expect(bundle.external_subject?.email).toBe('sso@dify.ai') + expect(bundle.external_subject?.issuer).toBe('https://issuer.example') + 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({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: 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({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: mock.url })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + })).rejects.toThrow(/expired/) + }) + + it('rejects http:// host without --insecure', async () => { + const io = bufferStreams() + const store = new MemStore() + await expect(runLogin({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: false, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: 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({ + configDir, + io, + host: mock.url, + noBrowser: true, + insecure: true, + deviceLabel: 'difyctl on test', + api: new DeviceFlowApi(createClient({ host: mock.url })), + store: { store, mode: 'file' }, + clock: noopClock, + browserOpener: noopBrowser, + }) + expect(io.errBuf()).toContain('--no-browser requested') + }) +}) diff --git a/cli/src/commands/auth/login/login.ts b/cli/src/commands/auth/login/login.ts new file mode 100644 index 0000000000..de05f52997 --- /dev/null +++ b/cli/src/commands/auth/login/login.ts @@ -0,0 +1,172 @@ +import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js' +import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js' +import type { TokenStore } from '../../../auth/store.js' +import type { IOStreams } from '../../../io/streams.js' +import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js' +import type { Clock } from './device-flow.js' +import * as os from 'node:os' +import * as readline from 'node:readline' +import { DeviceFlowApi } from '../../../api/oauth-device.js' +import { saveHosts } from '../../../auth/hosts.js' +import { selectStore } from '../../../auth/store.js' +import { createClient } from '../../../http/client.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' +import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js' +import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js' +import { awaitAuthorization, realClock } from './device-flow.js' + +export type LoginOptions = { + readonly configDir: string + 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 +} + +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(createClient({ host })) + 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 success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() }) + + const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir }) + const bundle = bundleFromSuccess(host, success, storeBundle.mode) + + await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token) + await saveHosts(opts.configDir, bundle) + + renderLoggedIn(opts.io.out, cs, host, success) + return bundle +} + +async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise { + let raw = opts.host?.trim() ?? '' + if (raw === '') + raw = await promptHost(opts.io) + return resolveHost({ raw, insecure }) +} + +async function promptHost(io: IOStreams): Promise { + io.err.write(`? Dify host [${DEFAULT_HOST}]: `) + const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false }) + try { + const line: string = await new Promise(resolve => rl.once('line', resolve)) + return line.trim() + } + finally { + rl.close() + } +} + +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 !== undefined && 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 bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle { + const display = bareHost(host) + let scheme: string | undefined + try { + const u = new URL(host) + if (u.protocol !== 'https:') + scheme = u.protocol.replace(':', '') + } + catch { /* keep undefined */ } + + const bundle: HostsBundle = { + current_host: display, + scheme, + token_storage: mode, + token_id: s.token_id, + tokens: { bearer: s.token }, + } + if (s.account !== undefined) { + bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name } + } + if (s.subject_email !== undefined && s.subject_email !== '' + && (s.account === undefined || s.account.id === '')) { + bundle.external_subject = { + email: s.subject_email, + issuer: s.subject_issuer ?? '', + } + } + const def = findDefaultWorkspace(s) + if (def !== undefined) + bundle.workspace = def + if (s.workspaces !== undefined && s.workspaces.length > 0) { + bundle.available_workspaces = s.workspaces.map(w => ({ + id: w.id, + name: w.name, + role: w.role, + })) + } + return bundle +} + +function accountKey(b: HostsBundle): string { + if (b.account?.id !== undefined && b.account.id !== '') + return b.account.id + if (b.external_subject?.email !== undefined && b.external_subject.email !== '') + return b.external_subject.email + return 'default' +} diff --git a/cli/src/commands/auth/logout/index.ts b/cli/src/commands/auth/logout/index.ts new file mode 100644 index 0000000000..c11ca97284 --- /dev/null +++ b/cli/src/commands/auth/logout/index.ts @@ -0,0 +1,40 @@ +import type { KyInstance } from 'ky' +import { loadHosts } from '../../../auth/hosts.js' +import { selectStore } from '../../../auth/store.js' +import { resolveConfigDir } from '../../../config/dir.js' +import { createClient } from '../../../http/client.js' +import { runWithSpinner } from '../../../io/spinner.js' +import { realStreams } from '../../../io/streams.js' +import { hostWithScheme } from '../../../util/host.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runLogout } from './logout.js' + +export default class Logout extends DifyCommand { + static override description = 'Log out of the active Dify host' + + static override examples = [ + '<%= config.bin %> auth logout', + ] + + async run(argv: string[]): Promise { + this.parse(Logout, argv) + const configDir = resolveConfigDir() + const bundle = await loadHosts(configDir) + const { store } = await selectStore({ configDir }) + + let http: KyInstance | undefined + if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') { + http = createClient({ + host: hostWithScheme(bundle.current_host, bundle.scheme), + bearer: bundle.tokens.bearer, + retryAttempts: 0, + }) + } + + const io = realStreams() + await runWithSpinner( + { io, label: 'Signing out', enabled: true, style: 'dify-dim' }, + () => runLogout({ configDir, io, bundle, http, store }), + ) + } +} diff --git a/cli/src/commands/auth/logout/logout.test.ts b/cli/src/commands/auth/logout/logout.test.ts new file mode 100644 index 0000000000..4fd3f53e8b --- /dev/null +++ b/cli/src/commands/auth/logout/logout.test.ts @@ -0,0 +1,143 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { TokenStore } from '../../../auth/store.js' +import { 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 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { saveHosts } from '../../../auth/hosts.js' +import { createClient } from '../../../http/client.js' +import { bufferStreams } from '../../../io/streams.js' +import { runLogout } from './logout.js' + +class MemStore implements TokenStore { + readonly entries = new Map() + async put(host: string, accountId: string, token: string): Promise { + this.entries.set(`${host}::${accountId}`, token) + } + + async get(host: string, accountId: string): Promise { + return this.entries.get(`${host}::${accountId}`) + } + + async delete(host: string, accountId: string): Promise { + this.entries.delete(`${host}::${accountId}`) + } + + async list(host: string): Promise { + const prefix = `${host}::` + return Array.from(this.entries.keys()) + .filter(k => k.startsWith(prefix)) + .map(k => k.slice(prefix.length)) + } +} + +function fixtureBundle(host: string): HostsBundle { + return { + current_host: host, + scheme: 'http', + token_storage: 'file', + token_id: 'tok-1', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +describe('runLogout', () => { + let mock: DifyMock + let configDir: string + + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-')) + }) + + afterEach(async () => { + await mock.stop() + await rm(configDir, { recursive: true, force: true }) + }) + + it('happy: revokes server side, clears local store + hosts.yml', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = fixtureBundle(mock.url) + await store.put(bundle.current_host, 'acct-1', 'dfoa_test') + await saveHosts(configDir, bundle) + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runLogout({ configDir, io, bundle, http, store }) + + expect(store.entries.size).toBe(0) + await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/) + expect(io.outBuf()).toContain('Logged out of') + expect(io.errBuf()).toBe('') + }) + + it('not-logged-in: throws BaseError', async () => { + const io = bufferStreams() + const store = new MemStore() + await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/) + }) + + it('hosts.yml absent: still completes locally + emits success', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = fixtureBundle(mock.url) + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runLogout({ configDir, io, bundle, http, store }) + + expect(io.outBuf()).toContain('Logged out of') + }) + + it('server revoke fails: warns to stderr but still clears local + exits 0', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = fixtureBundle(mock.url) + await store.put(bundle.current_host, 'acct-1', 'dfoa_test') + await saveHosts(configDir, bundle) + mock.setScenario('server-5xx') + const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }) + + await runLogout({ configDir, io, bundle, http, store }) + + expect(store.entries.size).toBe(0) + expect(io.errBuf()).toContain('server revoke failed') + expect(io.outBuf()).toContain('Logged out of') + }) + + it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = fixtureBundle(mock.url) + bundle.tokens = { bearer: 'dfp_personal_token' } + await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token') + await saveHosts(configDir, bundle) + const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' }) + + await runLogout({ configDir, io, bundle, http, store }) + + expect(io.errBuf()).toBe('') + expect(store.entries.size).toBe(0) + }) + + it('preserves unrelated files in configDir', async () => { + const io = bufferStreams() + const store = new MemStore() + const bundle = fixtureBundle(mock.url) + await saveHosts(configDir, bundle) + await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8') + const http = createClient({ host: mock.url, bearer: 'dfoa_test' }) + + await runLogout({ configDir, io, bundle, http, store }) + + const cfg = await readFile(join(configDir, 'config.yml'), 'utf8') + expect(cfg).toContain('foo: bar') + }) +}) diff --git a/cli/src/commands/auth/logout/logout.ts b/cli/src/commands/auth/logout/logout.ts new file mode 100644 index 0000000000..48660b6b35 --- /dev/null +++ b/cli/src/commands/auth/logout/logout.ts @@ -0,0 +1,70 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { TokenStore } from '../../../auth/store.js' +import type { IOStreams } from '../../../io/streams.js' +import { unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { AccountSessionsClient } from '../../../api/account-sessions.js' +import { HOSTS_FILE_NAME } from '../../../auth/hosts.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' + +export type LogoutOptions = { + readonly configDir: string + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly http?: KyInstance + readonly store: TokenStore +} + +export async function runLogout(opts: LogoutOptions): Promise { + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const bundle = opts.bundle + if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'not logged in', + hint: 'run \'difyctl auth login\'', + }) + } + + let revokeWarning = '' + if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) { + try { + const sessions = new AccountSessionsClient(opts.http) + await sessions.revokeSelf() + } + catch (err) { + revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n` + } + } + + await clearLocal(opts.configDir, bundle, opts.store) + + if (revokeWarning !== '') + opts.io.err.write(revokeWarning) + opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`) +} + +const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const + +function revokeAllowed(bearer: string): boolean { + return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p)) +} + +async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise { + const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default' + try { + await store.delete(bundle.current_host, accountId) + } + catch { /* best-effort */ } + const hostsPath = join(configDir, HOSTS_FILE_NAME) + try { + await unlink(hostsPath) + } + catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') + throw err + } +} diff --git a/cli/src/commands/auth/status/index.ts b/cli/src/commands/auth/status/index.ts new file mode 100644 index 0000000000..c779595d1f --- /dev/null +++ b/cli/src/commands/auth/status/index.ts @@ -0,0 +1,28 @@ +import { loadHosts } from '../../../auth/hosts.js' +import { resolveConfigDir } from '../../../config/dir.js' +import { Flags } from '../../../framework/flags.js' +import { realStreams } from '../../../io/streams.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runStatus } from './status.js' + +export default class Status extends DifyCommand { + static override description = 'Show authentication status for the active host' + + static override examples = [ + '<%= config.bin %> auth status', + '<%= config.bin %> auth status -v', + '<%= config.bin %> auth status --json', + ] + + static override flags = { + verbose: Flags.boolean({ char: 'v', description: 'show account/workspace ids and storage mode', default: false }), + json: Flags.boolean({ description: 'emit JSON', default: false }), + } + + async run(argv: string[]): Promise { + const { flags } = this.parse(Status, argv) + const configDir = resolveConfigDir() + const bundle = await loadHosts(configDir) + await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json }) + } +} diff --git a/cli/src/commands/auth/status/status.test.ts b/cli/src/commands/auth/status/status.test.ts new file mode 100644 index 0000000000..0000e9cd59 --- /dev/null +++ b/cli/src/commands/auth/status/status.test.ts @@ -0,0 +1,94 @@ +import type { HostsBundle } from '../../../auth/hosts.js' +import { describe, expect, it } from 'vitest' +import { bufferStreams } from '../../../io/streams.js' +import { runStatus } from './status.js' + +function accountBundle(): HostsBundle { + return { + current_host: 'cloud.dify.ai', + token_storage: 'keychain', + token_id: 'tok-1', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +function ssoBundle(): HostsBundle { + return { + current_host: 'cloud.dify.ai', + token_storage: 'file', + token_id: 'tok-sso-1', + tokens: { bearer: 'dfoe_test' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + } +} + +describe('runStatus', () => { + it('logged-out: prints message + throws NotLoggedIn', async () => { + const io = bufferStreams() + await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/) + expect(io.outBuf()).toContain('Not logged in') + }) + + it('logged-out json: emits {logged_in: false}', async () => { + const io = bufferStreams() + await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/) + expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false }) + }) + + it('account: human compact', async () => { + const io = bufferStreams() + await runStatus({ io, bundle: accountBundle() }) + const out = io.outBuf() + expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)') + expect(out).toContain('Workspace: Default') + expect(out).toContain('full access') + }) + + it('account verbose: shows ids + storage + workspace count', async () => { + const io = bufferStreams() + await runStatus({ io, bundle: accountBundle(), verbose: true }) + const out = io.outBuf() + expect(out).toContain('cloud.dify.ai') + expect(out).toContain('Account:') + expect(out).toContain('acct-1') + expect(out).toContain('Workspace: Default (ws-1, role: owner)') + expect(out).toContain('Available: 2 workspaces') + expect(out).toContain('Storage: keychain') + }) + + it('sso: human compact mentions issuer', async () => { + const io = bufferStreams() + await runStatus({ io, bundle: ssoBundle() }) + const out = io.outBuf() + expect(out).toContain('sso@dify.ai (via https://issuer.example)') + expect(out).toContain('apps:run') + }) + + it('account json: matches schema with workspace + workspace count', async () => { + const io = bufferStreams() + await runStatus({ io, bundle: accountBundle(), json: true }) + const parsed = JSON.parse(io.outBuf()) as Record + expect(parsed.host).toBe('cloud.dify.ai') + expect(parsed.logged_in).toBe(true) + expect(parsed.storage).toBe('keychain') + expect(parsed.account).toEqual({ id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }) + expect(parsed.workspace).toEqual({ id: 'ws-1', name: 'Default', role: 'owner' }) + expect(parsed.available_workspaces_count).toBe(2) + }) + + it('sso json: subject_type external_sso + email + issuer, no account', async () => { + const io = bufferStreams() + await runStatus({ io, bundle: ssoBundle(), json: true }) + const parsed = JSON.parse(io.outBuf()) as Record + expect(parsed.subject_type).toBe('external_sso') + expect(parsed.subject_email).toBe('sso@dify.ai') + expect(parsed.subject_issuer).toBe('https://issuer.example') + expect(parsed.account).toBeUndefined() + }) +}) diff --git a/cli/src/commands/auth/status/status.ts b/cli/src/commands/auth/status/status.ts new file mode 100644 index 0000000000..c666b08b0a --- /dev/null +++ b/cli/src/commands/auth/status/status.ts @@ -0,0 +1,91 @@ +import type { HostsBundle } from '../../../auth/hosts.js' +import type { IOStreams } from '../../../io/streams.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' + +export type StatusOptions = { + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly verbose?: boolean + readonly json?: boolean +} + +export async function runStatus(opts: StatusOptions): Promise { + const bundle = opts.bundle + if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') { + if (opts.json === true) { + opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`) + } + else { + opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n') + } + throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' }) + } + + if (opts.json === true) { + opts.io.out.write(`${renderJson(bundle)}\n`) + return + } + opts.io.out.write(renderHuman(bundle, opts.verbose ?? false)) +} + +function renderHuman(b: HostsBundle, verbose: boolean): string { + const lines: string[] = [] + if (!verbose) { + if (b.external_subject !== undefined) { + const sub = b.external_subject + lines.push(sub.issuer !== '' + ? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})` + : `Logged in to ${b.current_host} as ${sub.email} (via SSO)`) + lines.push(' Scope: apps:run') + return `${lines.join('\n')}\n` + } + const acc = b.account ?? { id: '', email: '', name: '' } + lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`) + if (b.workspace?.name !== undefined && b.workspace.name !== '') + lines.push(` Workspace: ${b.workspace.name}`) + lines.push(' Session: Dify account — full access') + return `${lines.join('\n')}\n` + } + + if (b.external_subject !== undefined) { + const sub = b.external_subject + lines.push(b.current_host) + lines.push(sub.issuer !== '' + ? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})` + : ` Subject: ${sub.email} (external SSO)`) + lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)') + lines.push(` Storage: ${b.token_storage}`) + return `${lines.join('\n')}\n` + } + const acc = b.account ?? { id: '', email: '', name: '' } + lines.push(b.current_host) + lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`) + if (b.workspace?.id !== undefined && b.workspace.id !== '') + lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`) + lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`) + lines.push(' Session: Dify account — full access (scope: full)') + lines.push(` Storage: ${b.token_storage}`) + return `${lines.join('\n')}\n` +} + +function renderJson(b: HostsBundle): string { + const out: Record = { + host: b.current_host, + logged_in: true, + storage: b.token_storage, + } + if (b.external_subject !== undefined) { + out.subject_type = 'external_sso' + out.subject_email = b.external_subject.email + out.subject_issuer = b.external_subject.issuer + } + else if (b.account !== undefined) { + out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name } + if (b.workspace?.id !== undefined && b.workspace.id !== '') { + out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role } + } + out.available_workspaces_count = b.available_workspaces?.length ?? 0 + } + return JSON.stringify(out, null, 2) +} diff --git a/cli/src/commands/auth/use/index.ts b/cli/src/commands/auth/use/index.ts new file mode 100644 index 0000000000..5803e6450e --- /dev/null +++ b/cli/src/commands/auth/use/index.ts @@ -0,0 +1,25 @@ +import { loadHosts } from '../../../auth/hosts.js' +import { resolveConfigDir } from '../../../config/dir.js' +import { Args } from '../../../framework/flags.js' +import { realStreams } from '../../../io/streams.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runUse } from './use.js' + +export default class Use extends DifyCommand { + static override description = 'Switch the active workspace for the current host' + + static override examples = [ + '<%= config.bin %> auth use ws-abc123', + ] + + static override args = { + workspaceId: Args.string({ description: 'workspace id to activate', required: true }), + } + + async run(argv: string[]): Promise { + const { args } = this.parse(Use, argv) + const configDir = resolveConfigDir() + const bundle = await loadHosts(configDir) + await runUse({ configDir, io: realStreams(), bundle, workspaceId: args.workspaceId }) + } +} diff --git a/cli/src/commands/auth/use/use.test.ts b/cli/src/commands/auth/use/use.test.ts new file mode 100644 index 0000000000..178785a630 --- /dev/null +++ b/cli/src/commands/auth/use/use.test.ts @@ -0,0 +1,71 @@ +import type { HostsBundle } from '../../../auth/hosts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { loadHosts, saveHosts } from '../../../auth/hosts.js' +import { bufferStreams } from '../../../io/streams.js' +import { runUse } from './use.js' + +function accountBundle(): HostsBundle { + return { + current_host: 'cloud.dify.ai', + token_storage: 'file', + token_id: 'tok-1', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +describe('runUse', () => { + let configDir: string + beforeEach(async () => { + configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-')) + }) + afterEach(async () => { + await rm(configDir, { recursive: true, force: true }) + }) + + it('switches workspace + persists hosts.yml', async () => { + const io = bufferStreams() + const b = accountBundle() + await saveHosts(configDir, b) + const next = await runUse({ configDir, io, bundle: b, workspaceId: 'ws-2' }) + expect(next.workspace).toEqual({ id: 'ws-2', name: 'Other', role: 'normal' }) + const reloaded = await loadHosts(configDir) + expect(reloaded?.workspace?.id).toBe('ws-2') + expect(io.outBuf()).toContain('Switched to workspace Other (ws-2)') + }) + + it('not-logged-in: throws NotLoggedIn', async () => { + const io = bufferStreams() + await expect(runUse({ configDir, io, bundle: undefined, workspaceId: 'ws-1' })) + .rejects + .toThrow(/not logged in/) + }) + + it('sso: throws workspace-unavailable', async () => { + const io = bufferStreams() + const b: HostsBundle = { + current_host: 'cloud.dify.ai', + token_storage: 'file', + tokens: { bearer: 'dfoe_test' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + } + await expect(runUse({ configDir, io, bundle: b, workspaceId: 'ws-1' })) + .rejects + .toThrow(/workspace context unavailable/) + }) + + it('unknown workspace: throws UsageMissingArg', async () => { + const io = bufferStreams() + await expect(runUse({ configDir, io, bundle: accountBundle(), workspaceId: 'ws-bogus' })) + .rejects + .toThrow(/ws-bogus.*not found/) + }) +}) diff --git a/cli/src/commands/auth/use/use.ts b/cli/src/commands/auth/use/use.ts new file mode 100644 index 0000000000..04454785b2 --- /dev/null +++ b/cli/src/commands/auth/use/use.ts @@ -0,0 +1,49 @@ +import type { HostsBundle, Workspace } from '../../../auth/hosts.js' +import type { IOStreams } from '../../../io/streams.js' +import { saveHosts } from '../../../auth/hosts.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' + +export type UseOptions = { + readonly configDir: string + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly workspaceId: string +} + +export async function runUse(opts: UseOptions): Promise { + const cs = colorScheme(colorEnabled(opts.io.isErrTTY)) + const b = opts.bundle + if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'not logged in', + hint: 'run \'difyctl auth login\'', + }) + } + if (b.external_subject !== undefined) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: 'workspace context unavailable for external SSO sessions', + hint: 'external SSO subjects don\'t carry tenant memberships in difyctl', + }) + } + + const found = (b.available_workspaces ?? []).find(w => w.id === opts.workspaceId) + if (found === undefined) { + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: `workspace "${opts.workspaceId}" not found in available_workspaces; run 'difyctl auth status' to list`, + }) + } + + const next: HostsBundle = { ...b, workspace: pickWorkspace(found) } + await saveHosts(opts.configDir, next) + opts.io.out.write(`${cs.successIcon()} Switched to workspace ${found.name} (${found.id})\n`) + return next +} + +function pickWorkspace(w: Workspace): Workspace { + return { id: w.id, name: w.name, role: w.role } +} diff --git a/cli/src/commands/auth/whoami/index.ts b/cli/src/commands/auth/whoami/index.ts new file mode 100644 index 0000000000..dbf51fb1e3 --- /dev/null +++ b/cli/src/commands/auth/whoami/index.ts @@ -0,0 +1,26 @@ +import { loadHosts } from '../../../auth/hosts.js' +import { resolveConfigDir } from '../../../config/dir.js' +import { Flags } from '../../../framework/flags.js' +import { realStreams } from '../../../io/streams.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runWhoami } from './whoami.js' + +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 configDir = resolveConfigDir() + const bundle = await loadHosts(configDir) + await runWhoami({ io: realStreams(), bundle, json: flags.json }) + } +} diff --git a/cli/src/commands/auth/whoami/whoami.test.ts b/cli/src/commands/auth/whoami/whoami.test.ts new file mode 100644 index 0000000000..f38a4b634f --- /dev/null +++ b/cli/src/commands/auth/whoami/whoami.test.ts @@ -0,0 +1,72 @@ +import type { HostsBundle } from '../../../auth/hosts.js' +import { describe, expect, it } from 'vitest' +import { bufferStreams } from '../../../io/streams.js' +import { runWhoami } from './whoami.js' + +function accountBundle(): HostsBundle { + return { + current_host: 'cloud.dify.ai', + token_storage: 'keychain', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + } +} + +describe('runWhoami', () => { + it('logged-out: throws NotLoggedIn', async () => { + const io = bufferStreams() + await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/) + }) + + it('account human: emits "email (name)"', async () => { + const io = bufferStreams() + await runWhoami({ io, bundle: accountBundle() }) + expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n') + }) + + it('account human, no name: emits email only', async () => { + const io = bufferStreams() + const b = accountBundle() + b.account!.name = '' + await runWhoami({ io, bundle: b }) + expect(io.outBuf()).toBe('tester@dify.ai\n') + }) + + it('account json: emits {id, email, name}', async () => { + const io = bufferStreams() + await runWhoami({ io, bundle: accountBundle(), json: true }) + expect(JSON.parse(io.outBuf())).toEqual({ + id: 'acct-1', + email: 'tester@dify.ai', + name: 'Test Tester', + }) + }) + + it('sso human: emits email + issuer', async () => { + const io = bufferStreams() + const b: HostsBundle = { + current_host: 'cloud.dify.ai', + token_storage: 'file', + tokens: { bearer: 'dfoe_test' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + } + await runWhoami({ io, bundle: b }) + 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() + const b: HostsBundle = { + current_host: 'cloud.dify.ai', + token_storage: 'file', + tokens: { bearer: 'dfoe_test' }, + external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' }, + } + await runWhoami({ io, bundle: b, 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 new file mode 100644 index 0000000000..fca750ae86 --- /dev/null +++ b/cli/src/commands/auth/whoami/whoami.ts @@ -0,0 +1,46 @@ +import type { HostsBundle } from '../../../auth/hosts.js' +import type { IOStreams } from '../../../io/streams.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' + +export type WhoamiOptions = { + readonly io: IOStreams + readonly bundle: HostsBundle | undefined + readonly json?: boolean +} + +export async function runWhoami(opts: WhoamiOptions): Promise { + const b = opts.bundle + if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') { + throw new BaseError({ + code: ErrorCode.NotLoggedIn, + message: 'not logged in', + hint: 'run \'difyctl auth login\'', + }) + } + + if (b.external_subject !== undefined) { + if (opts.json === true) { + opts.io.out.write(`${JSON.stringify({ + subject_type: 'external_sso', + email: b.external_subject.email, + issuer: b.external_subject.issuer, + })}\n`) + return + } + const sub = b.external_subject + opts.io.out.write(sub.issuer !== '' + ? `${sub.email} (external SSO, issuer: ${sub.issuer})\n` + : `${sub.email} (external SSO)\n`) + return + } + + const acc = b.account ?? { id: '', email: '', name: '' } + 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 new file mode 100644 index 0000000000..1505077f98 --- /dev/null +++ b/cli/src/commands/config/get/index.ts @@ -0,0 +1,22 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { Args } from '../../../framework/flags.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runConfigGet } from './run.js' + +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({ dir: resolveConfigDir(), key: args.key })) + } +} diff --git a/cli/src/commands/config/get/run.test.ts b/cli/src/commands/config/get/run.test.ts new file mode 100644 index 0000000000..7274a6e624 --- /dev/null +++ b/cli/src/commands/config/get/run.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { FILE_NAME } from '../../../config/schema.js' +import { isBaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { runConfigGet } from './run.js' + +describe('runConfigGet', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-get-')) + }) + + it('returns set value with trailing newline', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: yaml\n', + 'utf8', + ) + const out = await runConfigGet({ dir, 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({ dir, key: 'defaults.format' }) + expect(out).toBe('\n') + }) + + it('throws BaseError(config_invalid_key) on unknown key', async () => { + let caught: unknown + try { + await runConfigGet({ dir, 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 writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n limit: 75\n', + 'utf8', + ) + const out = await runConfigGet({ dir, 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 new file mode 100644 index 0000000000..0f43213318 --- /dev/null +++ b/cli/src/commands/config/get/run.ts @@ -0,0 +1,15 @@ +import type { ConfigFile } from '../../../config/schema.js' +import { getKey } from '../../../config/keys.js' +import { loadConfig } from '../../../config/loader.js' +import { emptyConfig } from '../../../config/schema.js' + +export type RunConfigGetOptions = { + readonly key: string + readonly dir: string +} + +export async function runConfigGet(opts: RunConfigGetOptions): Promise { + const loaded = await loadConfig(opts.dir) + 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 new file mode 100644 index 0000000000..1f529ec385 --- /dev/null +++ b/cli/src/commands/config/path/index.ts @@ -0,0 +1,17 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runConfigPath } from './run.js' + +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(runConfigPath({ dir: resolveConfigDir() })) + } +} diff --git a/cli/src/commands/config/path/run.test.ts b/cli/src/commands/config/path/run.test.ts new file mode 100644 index 0000000000..e9df22f85a --- /dev/null +++ b/cli/src/commands/config/path/run.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { runConfigPath } from './run.js' + +describe('runConfigPath', () => { + it('joins dir and config.yml with trailing newline', () => { + const out = runConfigPath({ dir: '/tmp/x' }) + expect(out).toBe('/tmp/x/config.yml\n') + }) + + it('handles trailing slash on dir', () => { + const out = runConfigPath({ dir: '/tmp/x/' }) + expect(out).toBe('/tmp/x/config.yml\n') + }) +}) diff --git a/cli/src/commands/config/path/run.ts b/cli/src/commands/config/path/run.ts new file mode 100644 index 0000000000..88c03bc14d --- /dev/null +++ b/cli/src/commands/config/path/run.ts @@ -0,0 +1,10 @@ +import { join } from 'node:path' +import { FILE_NAME } from '../../../config/schema.js' + +export type RunConfigPathOptions = { + readonly dir: string +} + +export function runConfigPath(opts: RunConfigPathOptions): string { + return `${join(opts.dir, FILE_NAME)}\n` +} diff --git a/cli/src/commands/config/set/index.ts b/cli/src/commands/config/set/index.ts new file mode 100644 index 0000000000..b8f22eed2b --- /dev/null +++ b/cli/src/commands/config/set/index.ts @@ -0,0 +1,24 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { Args } from '../../../framework/flags.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runConfigSet } from './run.js' + +export default class ConfigSet extends DifyCommand { + static override description = 'Set a config key (validates value)' + + 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({ dir: resolveConfigDir(), 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 new file mode 100644 index 0000000000..959b331344 --- /dev/null +++ b/cli/src/commands/config/set/run.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { FILE_NAME } from '../../../config/schema.js' +import { isBaseError } from '../../../errors/base.js' +import { ErrorCode, ExitCode } from '../../../errors/codes.js' +import { runConfigSet } from './run.js' + +describe('runConfigSet', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-set-')) + }) + + it('writes config.yml and returns "set k = v\\n"', async () => { + const out = await runConfigSet({ dir, key: 'defaults.format', value: 'json' }) + expect(out).toBe('set defaults.format = json\n') + const raw = await readFile(join(dir, FILE_NAME), 'utf8') + expect(raw).toContain('format: json') + }) + + it('rejects invalid format value with config_invalid_value', async () => { + let caught: unknown + try { + await runConfigSet({ dir, 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({ dir, 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({ dir, key: 'defaults.format', value: 'yaml' }) + await runConfigSet({ dir, key: 'defaults.limit', value: '40' }) + const raw = await readFile(join(dir, FILE_NAME), 'utf8') + expect(raw).toContain('format: yaml') + expect(raw).toContain('limit: 40') + }) + + it('exit code for invalid value is Usage (2)', async () => { + let caught: unknown + try { + await runConfigSet({ dir, 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({ dir, 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({ dir, 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 new file mode 100644 index 0000000000..d59b065a4d --- /dev/null +++ b/cli/src/commands/config/set/run.ts @@ -0,0 +1,19 @@ +import type { ConfigFile } from '../../../config/schema.js' +import { setKey } from '../../../config/keys.js' +import { loadConfig } from '../../../config/loader.js' +import { emptyConfig } from '../../../config/schema.js' +import { saveConfig } from '../../../config/writer.js' + +export type RunConfigSetOptions = { + readonly key: string + readonly value: string + readonly dir: string +} + +export async function runConfigSet(opts: RunConfigSetOptions): Promise { + const loaded = await loadConfig(opts.dir) + const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() + const next = setKey(config, opts.key, opts.value) + await saveConfig(opts.dir, 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 new file mode 100644 index 0000000000..f1e9a48be3 --- /dev/null +++ b/cli/src/commands/config/unset/index.ts @@ -0,0 +1,22 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { Args } from '../../../framework/flags.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runConfigUnset } from './run.js' + +export default class ConfigUnset extends DifyCommand { + static override description = 'Reset a config key to its zero value' + + 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({ dir: resolveConfigDir(), key: args.key })) + } +} diff --git a/cli/src/commands/config/unset/run.test.ts b/cli/src/commands/config/unset/run.test.ts new file mode 100644 index 0000000000..e67753149d --- /dev/null +++ b/cli/src/commands/config/unset/run.test.ts @@ -0,0 +1,47 @@ +import { mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { FILE_NAME } from '../../../config/schema.js' +import { isBaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { runConfigUnset } from './run.js' + +describe('runConfigUnset', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-')) + }) + + it('clears the requested key, leaves others intact', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: json\n limit: 25\n', + 'utf8', + ) + const out = await runConfigUnset({ dir, key: 'defaults.format' }) + expect(out).toBe('unset defaults.format\n') + const raw = await readFile(join(dir, FILE_NAME), 'utf8') + expect(raw).not.toContain('format:') + expect(raw).toContain('limit: 25') + }) + + it('is a no-op (writes empty config) when key was already unset', async () => { + const out = await runConfigUnset({ dir, key: 'defaults.format' }) + expect(out).toBe('unset defaults.format\n') + const raw = await readFile(join(dir, FILE_NAME), 'utf8') + expect(raw).toContain('schema_version: 1') + }) + + it('rejects unknown key', async () => { + let caught: unknown + try { + await runConfigUnset({ dir, 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 new file mode 100644 index 0000000000..8bd0a512a5 --- /dev/null +++ b/cli/src/commands/config/unset/run.ts @@ -0,0 +1,18 @@ +import type { ConfigFile } from '../../../config/schema.js' +import { unsetKey } from '../../../config/keys.js' +import { loadConfig } from '../../../config/loader.js' +import { emptyConfig } from '../../../config/schema.js' +import { saveConfig } from '../../../config/writer.js' + +export type RunConfigUnsetOptions = { + readonly key: string + readonly dir: string +} + +export async function runConfigUnset(opts: RunConfigUnsetOptions): Promise { + const loaded = await loadConfig(opts.dir) + const config: ConfigFile = loaded.found ? loaded.config : emptyConfig() + const next = unsetKey(config, opts.key) + await saveConfig(opts.dir, next) + return `unset ${opts.key}\n` +} diff --git a/cli/src/commands/config/view/index.ts b/cli/src/commands/config/view/index.ts new file mode 100644 index 0000000000..89401f4497 --- /dev/null +++ b/cli/src/commands/config/view/index.ts @@ -0,0 +1,23 @@ +import { resolveConfigDir } from '../../../config/dir.js' +import { Flags } from '../../../framework/flags.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runConfigView } from './run.js' + +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({ dir: resolveConfigDir(), json: flags.json })) + } +} diff --git a/cli/src/commands/config/view/run.test.ts b/cli/src/commands/config/view/run.test.ts new file mode 100644 index 0000000000..b3bc93115e --- /dev/null +++ b/cli/src/commands/config/view/run.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { FILE_NAME } from '../../../config/schema.js' +import { runConfigView } from './run.js' + +describe('runConfigView', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-view-')) + }) + + afterEach(async () => { + // tmpdir cleanup is best-effort + }) + + it('text format: empty config returns empty string', async () => { + const out = await runConfigView({ dir }) + expect(out).toBe('') + }) + + it('text format: emits "key = value" lines for set keys only', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n', + 'utf8', + ) + const out = await runConfigView({ dir }) + expect(out).toBe( + 'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n', + ) + }) + + it('text format: skips unset keys', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: yaml\n', + 'utf8', + ) + const out = await runConfigView({ dir }) + 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({ dir, json: true }) + expect(out).toBe('{}\n') + }) + + it('json format: defaults.limit is numeric, others are strings', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n', + 'utf8', + ) + const out = await runConfigView({ dir, 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({ dir, 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 new file mode 100644 index 0000000000..bda070ef46 --- /dev/null +++ b/cli/src/commands/config/view/run.ts @@ -0,0 +1,46 @@ +import type { ConfigFile } from '../../../config/schema.js' +import { knownKeyNames, lookupKey } from '../../../config/keys.js' +import { loadConfig } from '../../../config/loader.js' +import { emptyConfig } from '../../../config/schema.js' + +export type RunConfigViewOptions = { + readonly json?: boolean + readonly dir: string +} + +type ViewOut = Record + +export async function runConfigView(opts: RunConfigViewOptions): Promise { + const loaded = await loadConfig(opts.dir) + 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 new file mode 100644 index 0000000000..19a64664a8 --- /dev/null +++ b/cli/src/commands/coverage.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isExcludedCommandPath } from '../framework/command-fs.js' + +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/describe/app/handlers.ts b/cli/src/commands/describe/app/handlers.ts new file mode 100644 index 0000000000..ef0f56b103 --- /dev/null +++ b/cli/src/commands/describe/app/handlers.ts @@ -0,0 +1,67 @@ +import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen' +import type { AppMeta } from '../../../types/app-meta.js' + +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], + ['Author', info.author ?? ''], + ['Updated', info.updated_at ?? ''], + ['Service API', info.service_api_enabled ? 'true' : 'false'], + ['Tags', joinTags(info.tags ?? [])], + ] + 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 joinTags(tags: readonly TagItem[]): string { + if (tags.length === 0) + return '' + return tags.map(t => t.name).join(',') +} + +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 new file mode 100644 index 0000000000..514201bdf0 --- /dev/null +++ b/cli/src/commands/describe/app/index.ts @@ -0,0 +1,39 @@ +import { Args, Flags } from '../../../framework/flags.js' +import { formatted } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { httpRetryFlag } from '../../_shared/global-flags.js' +import { runDescribeApp } from './run.js' + +export default class DescribeApp extends DifyCommand { + static override description = 'Describe a single app (kubectl-describe-style)' + + static override examples = [ + '<%= config.bin %> describe app app-1', + '<%= config.bin %> describe app app-1 -o json', + '<%= config.bin %> describe app app-1 --refresh', + ] + + static override args = { + id: Args.string({ description: 'app id', required: true }), + } + + static override flags = { + 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }), + 'http-retry': httpRetryFlag, + 'output': Flags.string({ char: 'o', description: 'output format (json|yaml|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) + 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 }, + { bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, + ), + }) + } +} diff --git a/cli/src/commands/describe/app/run.test.ts b/cli/src/commands/describe/app/run.test.ts new file mode 100644 index 0000000000..5e35a0986f --- /dev/null +++ b/cli/src/commands/describe/app/run.test.ts @@ -0,0 +1,112 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../auth/hosts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { loadAppInfoCache } from '../../../cache/app-info.js' +import { formatted, stringifyOutput } from '../../../framework/output.js' +import { createClient } from '../../../http/client.js' +import { runDescribeApp } from './run.js' + +function bundle(): HostsBundle { + return { + current_host: 'http://localhost', + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +describe('runDescribeApp', () => { + let mock: DifyMock + let dir: string + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-')) + }) + afterEach(async () => { + await mock.stop() + await rm(dir, { recursive: true, force: true }) + }) + + async function render(opts: Parameters[0]): Promise { + const cache = await loadAppInfoCache({ configDir: dir }) + const data = await runDescribeApp( + opts, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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('Tags:') + expect(out).toContain('demo') + expect(out).toContain('Description:') + expect(out).toContain('Parameters:') + }) + + it('text: agent app shows Agent: true', async () => { + const out = await render({ appId: 'app-4', workspace: 'ws-2' }) + 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({ configDir: dir }) + await runDescribeApp( + { appId: 'app-1' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache }, + ) + const before = cache.get(mock.url, 'app-1') + expect(before).toBeDefined() + await runDescribeApp( + { appId: 'app-1', refresh: true }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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' }, + { + bundle: bundle(), + http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), + host: mock.url, + }, + )).rejects.toThrow() + }) +}) diff --git a/cli/src/commands/describe/app/run.ts b/cli/src/commands/describe/app/run.ts new file mode 100644 index 0000000000..089274b8f1 --- /dev/null +++ b/cli/src/commands/describe/app/run.ts @@ -0,0 +1,44 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { AppInfoCache } from '../../../cache/app-info.js' +import type { IOStreams } from '../../../io/streams.js' +import { AppMetaClient } from '../../../api/app-meta.js' +import { AppsClient } from '../../../api/apps.js' +import { runWithSpinner } from '../../../io/spinner.js' +import { nullStreams } from '../../../io/streams.js' +import { FieldInfo, FieldInputSchema, FieldParameters } from '../../../types/app-meta.js' +import { resolveWorkspaceId } from '../../../workspace/resolver.js' +import { AppDescribeOutput } from './handlers.js' + +export type DescribeAppOptions = { + readonly appId: string + readonly workspace?: string + readonly format?: string + readonly refresh?: boolean +} + +export type DescribeAppDeps = { + readonly bundle: HostsBundle + readonly http: KyInstance + 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 env = deps.envLookup ?? ((k: string) => process.env[k]) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const apps = new AppsClient(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, wsId, [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 new file mode 100644 index 0000000000..1ecc96fe01 --- /dev/null +++ b/cli/src/commands/env/list/index.ts @@ -0,0 +1,22 @@ +import { Flags } from '../../../framework/flags.js' +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runEnvList } from './run-list.js' + +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 new file mode 100644 index 0000000000..01e6dfcfd0 --- /dev/null +++ b/cli/src/commands/env/list/run-list.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { runEnvList } from './run-list.js' + +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 new file mode 100644 index 0000000000..2fce948fc5 --- /dev/null +++ b/cli/src/commands/env/list/run-list.ts @@ -0,0 +1,73 @@ +import { ENV_REGISTRY } from '../../../env/registry.js' + +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 ?? defaultLookup + 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 +} + +function defaultLookup(name: string): string | undefined { + return process.env[name] +} diff --git a/cli/src/commands/get/app/handlers.ts b/cli/src/commands/get/app/handlers.ts new file mode 100644 index 0000000000..e0646e267a --- /dev/null +++ b/cli/src/commands/get/app/handlers.ts @@ -0,0 +1,77 @@ +import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen' +import type { TableCell } from '../../../framework/output.js' +import type { TableColumn } from '../../../printers/format-table.js' + +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: 'TAGS', priority: 0 }, + { name: 'UPDATED', priority: 0 }, + { name: 'AUTHOR', priority: 1 }, + { 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, + joinTags(this.data.tags ?? []), + this.data.updated_at ?? '', + this.data.created_by_name ?? '', + 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 + } +} + +function joinTags(tags: readonly TagItem[]): string { + return tags.map(t => t.name).join(',') +} diff --git a/cli/src/commands/get/app/index.ts b/cli/src/commands/get/app/index.ts new file mode 100644 index 0000000000..c5ce8516aa --- /dev/null +++ b/cli/src/commands/get/app/index.ts @@ -0,0 +1,68 @@ +import type { AppMode } from '@dify/contracts/api/openapi/types.gen' +import { Args, Flags } from '../../../framework/flags.js' +import { table } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { httpRetryFlag } from '../../_shared/global-flags.js' +import { runGetApp } from './run.js' + +const APP_MODE_VALUES: readonly AppMode[] = [ + 'advanced-chat', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', +] + +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)' }), + 'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }), + 'http-retry': httpRetryFlag, + 'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|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, + name: flags.name, + tag: flags.tag, + format, + }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }) + return table({ + format, + data: result.data, + }) + } +} diff --git a/cli/src/commands/get/app/payload-shape.ts b/cli/src/commands/get/app/payload-shape.ts new file mode 100644 index 0000000000..53f638eb86 --- /dev/null +++ b/cli/src/commands/get/app/payload-shape.ts @@ -0,0 +1,5 @@ +export function isPayloadShape(value: unknown, requiredKey: keyof T): value is T { + return typeof value === 'object' + && value !== null + && requiredKey in value +} diff --git a/cli/src/commands/get/app/run.test.ts b/cli/src/commands/get/app/run.test.ts new file mode 100644 index 0000000000..ebabf57fe0 --- /dev/null +++ b/cli/src/commands/get/app/run.test.ts @@ -0,0 +1,140 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../auth/hosts.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { stringifyOutput, table } from '../../../framework/output.js' +import { createClient } from '../../../http/client.js' +import { AppListOutput } from './handlers.js' +import { runGetApp } from './run.js' + +const baseBundle: HostsBundle = { + current_host: '127.0.0.1', + scheme: 'http', + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, +} + +describe('runGetApp', () => { + let mock: DifyMock + + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + }) + + afterEach(async () => { + await mock.stop() + }) + + function http() { + return createClient({ host: mock.url, bearer: 'dfoa_test' }) + } + + async function render(opts: Parameters[0] = {}): Promise { + const result = await runGetApp(opts, { bundle: baseBundle, http: http() }) + return stringifyOutput(table({ + format: opts.format ?? '', + data: result.data, + })) + } + + it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => { + const out = await render() + expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/) + expect(out).toContain('Greeter') + expect(out).toContain('app-1') + expect(out).toContain('chat') + expect(out).toContain('demo') + 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', + 'TAGS', + 'UPDATED', + 'AUTHOR', + '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('--tag filters server-side', async () => { + const out = await render({ tag: 'demo' }) + expect(out).toContain('Greeter') + expect(out).not.toContain('Workflow') + }) + + 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 AUTHOR and WORKSPACE columns', async () => { + const out = await render({ format: 'wide' }) + expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/) + expect(out).toContain('tester') + 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: 'ws-2' }) + 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: HostsBundle = { current_host: 'h', token_storage: 'file' } + await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/) + }) +}) diff --git a/cli/src/commands/get/app/run.ts b/cli/src/commands/get/app/run.ts new file mode 100644 index 0000000000..4bfb300fb1 --- /dev/null +++ b/cli/src/commands/get/app/run.ts @@ -0,0 +1,168 @@ +import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen' +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { IOStreams } from '../../../io/streams.js' +import { AppsClient } from '../../../api/apps.js' +import { WorkspacesClient } from '../../../api/workspaces.js' +import { runWithSpinner } from '../../../io/spinner.js' +import { nullStreams } from '../../../io/streams.js' +import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js' +import { resolveWorkspaceId } from '../../../workspace/resolver.js' +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?: string + readonly name?: string + readonly tag?: string + readonly format?: string +} + +export type GetAppDeps = { + readonly bundle: HostsBundle + readonly http: KyInstance + readonly io?: IOStreams + readonly envLookup?: (k: string) => string | undefined + readonly appsFactory?: (http: KyInstance) => AppsClient + readonly workspacesFactory?: (http: KyInstance) => 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 ?? ((k: string) => process.env[k]) + const appsFactory = deps.appsFactory ?? ((h: KyInstance) => new AppsClient(h)) + const wsFactory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h)) + + const apps = appsFactory(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) { + const ws = wsFactory(deps.http) + return runAllWorkspaces(apps, ws, opts, page, pageSize) + } + if (opts.appId !== undefined && opts.appId !== '') { + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const wsName = workspaceNameForId(deps.bundle, wsId) + const desc = await apps.describe(opts.appId, wsId, ['info']) + return describeToEnvelope(desc, wsId, wsName) + } + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + return apps.list({ + workspaceId: wsId, + page, + limit: pageSize, + mode: opts.mode, + name: opts.name, + tag: opts.tag, + }) + }, + ) + + 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, + tags: desc.info.tags, + updated_at: desc.info.updated_at, + created_by_name: desc.info.author === '' ? undefined : desc.info.author, + workspace_id: wsId, + workspace_name: wsName === '' ? undefined : wsName, + }], + } +} + +function workspaceNameForId(b: HostsBundle, id: string): string { + if (id === '') + return '' + if (b.workspace?.id === id) + return b.workspace.name + for (const w of b.available_workspaces ?? []) { + if (w.id === id) + return w.name + } + return '' +} + +async function runAllWorkspaces( + apps: AppsClient, + ws: WorkspacesClient, + opts: GetAppOptions, + page: number, + limit: number, +): Promise { + const wsResp = await ws.list() + if (wsResp.workspaces.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.workspaces] + const workers: Promise[] = [] + + const fetchOne = async (wsId: string): Promise => { + const env = await apps.list({ + workspaceId: wsId, + page, + limit, + mode: opts.mode, + name: opts.name, + tag: opts.tag, + }) + 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.workspaces.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/workspace/handlers.test.ts b/cli/src/commands/get/workspace/handlers.test.ts new file mode 100644 index 0000000000..cacbcf4b3e --- /dev/null +++ b/cli/src/commands/get/workspace/handlers.test.ts @@ -0,0 +1,36 @@ +import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' +import { describe, expect, it } from 'vitest' +import { newWorkspaceObject, WORKSPACE_MODE_KEY, WorkspaceListOutput, WorkspaceRow } from './handlers.js' + +function env(): WorkspaceListResponse { + return { + workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }, + { id: 'ws-2', name: 'Other', role: 'normal', status: 'normal', current: false }, + ], + } +} + +describe('get/workspace handlers', () => { + it('newWorkspaceObject mode = workspace + raw passthrough', () => { + const obj = newWorkspaceObject(env()) + expect(obj.mode()).toBe(WORKSPACE_MODE_KEY) + expect(obj.raw().workspaces[0]?.id).toBe('ws-1') + }) + + 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().workspaces).toHaveLength(2) + }) +}) diff --git a/cli/src/commands/get/workspace/handlers.ts b/cli/src/commands/get/workspace/handlers.ts new file mode 100644 index 0000000000..a71e8a6f64 --- /dev/null +++ b/cli/src/commands/get/workspace/handlers.ts @@ -0,0 +1,120 @@ +import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen' +import type { TableCell } from '../../../framework/output.js' +import type { TableColumn, TableHandler, TableRow } from '../../../printers/format-table.js' +import { isPayloadShape } from '../app/payload-shape.js' + +export const WORKSPACE_MODE_KEY = 'workspace' +const CURRENT_MARKER = '*' + +export type WorkspaceObject = { + mode: () => string + raw: () => WorkspaceListResponse +} + +export function newWorkspaceObject(env: WorkspaceListResponse): WorkspaceObject { + return { + mode: () => WORKSPACE_MODE_KEY, + raw: () => env, + } +} + +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 + } +} + +export function workspaceTableHandler(currentId: string): TableHandler { + return { + columns: () => WORKSPACE_COLUMNS, + rows: (raw): readonly TableRow[] => { + if (!isPayloadShape(raw, 'workspaces')) + throw new Error('get/workspace table: unexpected payload shape') + return raw.workspaces.map(w => [ + w.id, + w.name, + w.role, + w.status, + w.current || (currentId !== '' && w.id === currentId) ? CURRENT_MARKER : '', + ]) + }, + } +} diff --git a/cli/src/commands/get/workspace/index.ts b/cli/src/commands/get/workspace/index.ts new file mode 100644 index 0000000000..f1edd17b03 --- /dev/null +++ b/cli/src/commands/get/workspace/index.ts @@ -0,0 +1,33 @@ +import { Flags } from '../../../framework/flags.js' +import { raw, table } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { httpRetryFlag } from '../../_shared/global-flags.js' +import { runGetWorkspace } from './run.js' + +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.string({ char: 'o', description: 'output format (json|yaml|name|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 }, { bundle: ctx.bundle, 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 new file mode 100644 index 0000000000..7ef51c9d81 --- /dev/null +++ b/cli/src/commands/get/workspace/run.test.ts @@ -0,0 +1,120 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../auth/hosts.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { stringifyOutput, table } from '../../../framework/output.js' +import { createClient } from '../../../http/client.js' +import { WorkspaceListOutput } from './handlers.js' +import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js' + +const baseBundle: HostsBundle = { + current_host: '127.0.0.1', + scheme: 'http', + account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, +} + +describe('runGetWorkspace', () => { + let mock: DifyMock + + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + }) + + afterEach(async () => { + await mock.stop() + }) + + function http() { + return createClient({ host: mock.url, bearer: 'dfoa_test' }) + } + + async function render(format = '', bundle = baseBundle): Promise { + const result = await runGetWorkspace({ format }, { bundle, 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('ws-1') + expect(out).toContain('ws-2') + 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('ws-1')) + expect(line).toContain('*') + else if (line.includes('ws-2')) + expect(line).not.toContain('*') + } + }) + + it('falls back to bundle workspace.id when server current=false', async () => { + const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } + const out = await render('', overridden) + for (const line of out.split('\n')) { + if (line.includes('ws-2')) + expect(line).toContain('*') + } + }) + + it('-o json emits a parseable workspaces envelope', async () => { + const out = await render('json') + const parsed = JSON.parse(out) as { workspaces: Array<{ id: string, status: string, current: boolean }> } + expect(parsed.workspaces).toHaveLength(2) + expect(parsed.workspaces.map(w => w.id).sort()).toEqual(['ws-1', 'ws-2']) + expect(parsed.workspaces[0]?.status).toBe('normal') + expect(parsed.workspaces[0]?.current).toBe(true) + }) + + it('-o yaml emits "workspaces:" header', async () => { + const out = await render('yaml') + expect(out).toContain('workspaces:') + expect(out).toContain('ws-1') + }) + + it('-o name emits ids joined by newline', async () => { + const out = await render('name') + expect(out.trim().split('\n').sort()).toEqual(['ws-1', 'ws-2']) + }) + + 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 new file mode 100644 index 0000000000..f2015f4817 --- /dev/null +++ b/cli/src/commands/get/workspace/run.ts @@ -0,0 +1,47 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { IOStreams } from '../../../io/streams.js' +import { WorkspacesClient } from '../../../api/workspaces.js' +import { runWithSpinner } from '../../../io/spinner.js' +import { nullStreams } from '../../../io/streams.js' +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 bundle: HostsBundle + readonly http: KyInstance + readonly io?: IOStreams + readonly workspacesFactory?: (http: KyInstance) => 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: KyInstance) => new WorkspacesClient(h)) + const io = deps.io ?? nullStreams() + const env = await runWithSpinner( + { io, label: 'Fetching workspaces' }, + () => wsFactory(deps.http).list(), + ) + if (env.workspaces.length === 0) + return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE } + const currentId = deps.bundle.workspace?.id ?? '' + return { + kind: 'output', + data: new WorkspaceListOutput(env.workspaces.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/help/account/account.test.ts b/cli/src/commands/help/account/account.test.ts new file mode 100644 index 0000000000..162fbda78a --- /dev/null +++ b/cli/src/commands/help/account/account.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { runHelpAccount } from './account.js' + +describe('runHelpAccount', () => { + it('mentions auth login device flow', () => { + expect(runHelpAccount()).toContain('difyctl auth login') + }) + + it('mentions get/describe/run app commands', () => { + const out = runHelpAccount() + 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 = runHelpAccount() + expect(out).toContain('--workspace') + expect(out).toContain('difyctl env list') + }) +}) diff --git a/cli/src/commands/help/account/account.ts b/cli/src/commands/help/account/account.ts new file mode 100644 index 0000000000..8cbf5e28e0 --- /dev/null +++ b/cli/src/commands/help/account/account.ts @@ -0,0 +1,23 @@ +export 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 + + 4. Run an app and capture structured output: + difyctl run app "hello" -o json + +Tips: + * Pass --workspace when you need to target a non-default workspace. + * Use --stream for long-running workflow calls (post-v1.0 milestone). + * 'difyctl env list' shows every env var difyctl reads. +` + +export function runHelpAccount(): string { + return ACCOUNT_HELP_TEXT +} diff --git a/cli/src/commands/help/account/index.ts b/cli/src/commands/help/account/index.ts new file mode 100644 index 0000000000..a9adfe0022 --- /dev/null +++ b/cli/src/commands/help/account/index.ts @@ -0,0 +1,16 @@ +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runHelpAccount } from './account.js' + +export default class HelpAccount extends DifyCommand { + static override description = 'Agent-onboarding text for account bearers (dfoa_)' + + static override examples = [ + '<%= config.bin %> help account', + ] + + async run(argv: string[]) { + this.parse(HelpAccount, argv) + return raw(runHelpAccount()) + } +} diff --git a/cli/src/commands/help/environment/environment.test.ts b/cli/src/commands/help/environment/environment.test.ts new file mode 100644 index 0000000000..d6aad702b4 --- /dev/null +++ b/cli/src/commands/help/environment/environment.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { ENV_REGISTRY } from '../../../env/registry.js' +import { runHelpEnvironment } from './environment.js' + +describe('runHelpEnvironment', () => { + it('starts with the ENVIRONMENT VARIABLES header', () => { + expect(runHelpEnvironment().startsWith('ENVIRONMENT VARIABLES\n\n')).toBe(true) + }) + + it('lists every var from ENV_REGISTRY with its description', () => { + const out = runHelpEnvironment() + 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 = runHelpEnvironment() + 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/commands/help/environment/environment.ts b/cli/src/commands/help/environment/environment.ts new file mode 100644 index 0000000000..279c7de6d6 --- /dev/null +++ b/cli/src/commands/help/environment/environment.ts @@ -0,0 +1,12 @@ +import { ENV_REGISTRY } from '../../../env/registry.js' + +export function runHelpEnvironment(): 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 +} diff --git a/cli/src/commands/help/environment/index.ts b/cli/src/commands/help/environment/index.ts new file mode 100644 index 0000000000..0dfadf9d2f --- /dev/null +++ b/cli/src/commands/help/environment/index.ts @@ -0,0 +1,16 @@ +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runHelpEnvironment } from './environment.js' + +export default class HelpEnvironment extends DifyCommand { + static override description = 'Long-form documentation for every DIFY_* env var' + + static override examples = [ + '<%= config.bin %> help environment', + ] + + async run(argv: string[]) { + this.parse(HelpEnvironment, argv) + return raw(runHelpEnvironment()) + } +} diff --git a/cli/src/commands/help/external/external.test.ts b/cli/src/commands/help/external/external.test.ts new file mode 100644 index 0000000000..9925fc6c76 --- /dev/null +++ b/cli/src/commands/help/external/external.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { runHelpExternal } from './external.js' + +describe('runHelpExternal', () => { + it('mentions external bearer prefix and login flag', () => { + const out = runHelpExternal() + expect(out).toContain('dfoe_') + expect(out).toContain('--external') + expect(out).toContain('DIFY_TOKEN') + }) + + it('explains workspace empty-list expectation', () => { + expect(runHelpExternal()).toContain('get workspace') + }) +}) diff --git a/cli/src/commands/help/external/external.ts b/cli/src/commands/help/external/external.ts new file mode 100644 index 0000000000..19763a8b91 --- /dev/null +++ b/cli/src/commands/help/external/external.ts @@ -0,0 +1,26 @@ +export 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: + difyctl auth login --external --token "$DIFY_TOKEN" + + 3. List apps your subject is permitted to invoke: + difyctl get app + + 4. Run an app: + difyctl run app "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. +` + +export function runHelpExternal(): string { + return EXTERNAL_HELP_TEXT +} diff --git a/cli/src/commands/help/external/index.ts b/cli/src/commands/help/external/index.ts new file mode 100644 index 0000000000..8b52520eb3 --- /dev/null +++ b/cli/src/commands/help/external/index.ts @@ -0,0 +1,16 @@ +import { raw } from '../../../framework/output.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { runHelpExternal } from './external.js' + +export default class HelpExternal extends DifyCommand { + static override description = 'Agent-onboarding text for external-SSO bearers (dfoe_)' + + static override examples = [ + '<%= config.bin %> help external', + ] + + async run(argv: string[]) { + this.parse(HelpExternal, argv) + return raw(runHelpExternal()) + } +} diff --git a/cli/src/commands/resume/app/index.ts b/cli/src/commands/resume/app/index.ts new file mode 100644 index 0000000000..6498549493 --- /dev/null +++ b/cli/src/commands/resume/app/index.ts @@ -0,0 +1,54 @@ +import { Args, Flags } from '../../../framework/flags.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { httpRetryFlag } from '../../_shared/global-flags.js' +import { resumeApp } from './run.js' + +export default class ResumeApp extends DifyCommand { + static override description = 'Resume a paused workflow app after submitting a human input form' + + 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. Strips ... blocks silently by default; with --think, thinking is printed to stderr.', default: false }), + 'output': Flags.string({ char: 'o', description: 'output format (json|yaml|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, + }, + { bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache }, + ) + } +} diff --git a/cli/src/commands/resume/app/run.ts b/cli/src/commands/resume/app/run.ts new file mode 100644 index 0000000000..06280ebaad --- /dev/null +++ b/cli/src/commands/resume/app/run.ts @@ -0,0 +1,159 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { AppInfoCache } from '../../../cache/app-info.js' +import type { IOStreams } from '../../../io/streams.js' +import type { RunContext } from '../../run/app/_strategies/index.js' +import { AppMetaClient } from '../../../api/app-meta.js' +import { AppRunClient } from '../../../api/app-run.js' +import { AppsClient } from '../../../api/apps.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' +import { FieldInfo } from '../../../types/app-meta.js' +import { resolveWorkspaceId } from '../../../workspace/resolver.js' +import { pickStrategy } from '../../run/app/_strategies/index.js' +import { RUN_MODES } from '../../run/app/handlers.js' +import { AppRunPrintFlags } from '../../run/app/print-flags.js' + +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 bundle: HostsBundle + readonly http: KyInstance + readonly host: string + readonly io: IOStreams + readonly cache?: AppInfoCache + readonly envLookup?: (k: string) => string | undefined + readonly exit?: (code: number) => never +} + +const TEXT_FORMATS = new Set(['', 'text']) + +async function resolveInputs( + inputsJson: string | undefined, + inputsFile: string | undefined, + directInputs: Readonly> | undefined, +): Promise> { + if (inputsJson !== undefined && inputsFile !== undefined) + throw new Error('--inputs and --inputs-file are mutually exclusive') + if (inputsJson !== undefined) { + let parsed: unknown + try { + parsed = JSON.parse(inputsJson) + } + catch { + throw new Error('--inputs must be valid JSON') + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error('--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 Error('--inputs-file must contain valid JSON') + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) + throw new Error('--inputs-file must be a JSON object') + return parsed as Record + } + return { ...(directInputs ?? {}) } +} + +export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise { + const env = deps.envLookup ?? ((k: string) => process.env[k]) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + + const apps = new AppsClient(deps.http) + const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) + const m = await meta.get(opts.appId, wsId, [FieldInfo]) + const mode = m.info?.mode ?? RUN_MODES.Workflow + + const runClient = new AppRunClient(deps.http) + const exit = deps.exit ?? ((code: number) => process.exit(code) as never) + + let action = opts.action + if (action === undefined) { + const formResp = await deps.http.get( + `apps/${encodeURIComponent(opts.appId)}/form/human_input/${encodeURIComponent(opts.formToken)}`, + ).json<{ user_actions: { id: string }[] }>() + 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 printFlags = new AppRunPrintFlags() + + 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, + printFlags, + 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 new file mode 100644 index 0000000000..1e7c9895e3 --- /dev/null +++ b/cli/src/commands/run/app/_strategies/index.ts @@ -0,0 +1,29 @@ +import type { AppRunClient } from '../../../../api/app-run.js' +import type { AppRunPrintFlags } from '../print-flags.js' +import type { RunAppDeps, RunAppOptions } from '../run.js' +import { StreamingStructuredStrategy } from './streaming-structured.js' +import { StreamingTextStrategy } from './streaming-text.js' + +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 printFlags: AppRunPrintFlags + 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 new file mode 100644 index 0000000000..b85db1d808 --- /dev/null +++ b/cli/src/commands/run/app/_strategies/streaming-structured.ts @@ -0,0 +1,99 @@ +import type { SseEvent } from '../../../../http/sse.js' +import type { RunContext, RunStrategy } from './index.js' +import { buildRunBody } from '../../../../api/app-run.js' +import { colorEnabled, colorScheme } from '../../../../io/color.js' +import { startSpinner } from '../../../../io/spinner.js' +import { extractThinkBlocks, stripThinkBlocks } from '../../../../io/think-filter.js' +import { chatConversationHint, newAppRunObject, RUN_MODES } from '../handlers.js' +import { renderHitlHint, renderHitlOutput } from '../hitl-render.js' +import { collect, HitlPauseError } from '../sse-collector.js' + +const CHAT_MODES: ReadonlySet = new Set([RUN_MODES.Chat, RUN_MODES.AgentChat, RUN_MODES.AdvancedChat]) + +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, printFlags, 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) + } + process.once('SIGINT', cleanup) + + let resp: Record + try { + const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal }) + 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() + process.off('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) } + } + } + + const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode + deps.io.out.write(printFlags.toPrinter(format).print(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 new file mode 100644 index 0000000000..6a5405a918 --- /dev/null +++ b/cli/src/commands/run/app/_strategies/streaming-text.ts @@ -0,0 +1,66 @@ +import type { RunContext, RunStrategy } from './index.js' +import { buildRunBody } from '../../../../api/app-run.js' +import { renderHitlHint, renderHitlOutput } from '../hitl-render.js' +import { decodeStreamError, HitlPauseError } from '../sse-collector.js' + +export class StreamingTextStrategy implements RunStrategy { + async execute(ctx: RunContext): Promise { + const { opts, deps, mode, printFlags, 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) + } + process.once('SIGINT', cleanup) + + try { + const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal }) + const sp = printFlags.toStreamPrinter(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 { + process.off('SIGINT', cleanup) + } + } +} diff --git a/cli/src/commands/run/app/agent-guide.test.ts b/cli/src/commands/run/app/agent-guide.test.ts new file mode 100644 index 0000000000..454d9eecbb --- /dev/null +++ b/cli/src/commands/run/app/agent-guide.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import RunApp from './index.js' + +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 new file mode 100644 index 0000000000..87babdedec --- /dev/null +++ b/cli/src/commands/run/app/file-flags.test.ts @@ -0,0 +1,113 @@ +import type { ParsedFileFlag } from './file-flags.js' +import { describe, expect, it, vi } from 'vitest' +import { difyFileType, parseFileFlag, resolveFileInputs } from './file-flags.js' + +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 new file mode 100644 index 0000000000..6fbb17e303 --- /dev/null +++ b/cli/src/commands/run/app/file-flags.ts @@ -0,0 +1,91 @@ +import { basename } from 'node:path' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' + +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 new file mode 100644 index 0000000000..186f9cc454 --- /dev/null +++ b/cli/src/commands/run/app/guide.ts @@ -0,0 +1,43 @@ +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 / advanced-chat Conversational. Accepts --conversation to + resume an existing thread. + completion Single-turn. Ignores --conversation. + workflow Multi-step graph. Pass all input variables as a + JSON object via --inputs. + agent-chat Conversational with autonomous tool use. + +FLAGS + --inputs '{"k":"v"}' All input variables as one JSON object. + --inputs '{"language":"English","topic":"AI safety"}' + --inputs-file path Load inputs from a JSON file. Mutually exclusive + with --inputs. + --file key=@path Named file input. Supports local files (--file key=@/path/to/file) + and remote URLs (--file key=https://url). Repeatable for multiple + file inputs. + --stream Print output live as tokens/events arrive. + --conversation Resume a conversation (chat/advanced-chat only). + --workspace Target a specific workspace. + +HITL PAUSE (exit code 2) + 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 new file mode 100644 index 0000000000..2cc11b026c --- /dev/null +++ b/cli/src/commands/run/app/handlers.ts @@ -0,0 +1,73 @@ +import type { ColorScheme } from '../../../io/color.js' +import type { TextHandler } from '../../../printers/format-text.js' + +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 type AppRunObject = { + mode: () => string + raw: () => Record +} + +export function newAppRunObject(mode: string, resp: Record): AppRunObject { + const filled = resp.mode === undefined || resp.mode === '' ? { ...resp, mode } : resp + return { mode: () => mode, raw: () => filled } +} + +export const chatTextHandler: TextHandler = { + render(raw): string { + const resp = raw as Record + const out: string[] = [] + const answer = pickString(resp, 'answer') + if (answer !== undefined) + out.push(answer) + out.push('') + return out.join('\n') + }, +} + +export const completionTextHandler: TextHandler = { + render(raw): string { + const resp = raw as Record + const answer = pickString(resp, 'answer') + return `${answer ?? ''}\n` + }, +} + +export const workflowTextHandler: TextHandler = { + render(raw): string { + const resp = raw as Record + const data = resp.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(resp)}\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.ts b/cli/src/commands/run/app/hitl-render.ts new file mode 100644 index 0000000000..f9c3ed6ac9 --- /dev/null +++ b/cli/src/commands/run/app/hitl-render.ts @@ -0,0 +1,115 @@ +import type { HitlPausePayload } from './sse-collector.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' + +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 + 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, + 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` +} + +const EXTERNAL_CHANNEL_NOTE = 'form delivered via email/external channel — resume only from that channel' + +export function renderHitlHint(appId: string, payload: HitlPausePayload, isErrTTY: boolean): string { + const d = payload.data + const cs = colorScheme(colorEnabled(isErrTTY)) + if (d.form_token === null) { + if (!isErrTTY) + return `hint: workflow paused — ${EXTERNAL_CHANNEL_NOTE}\n` + return `${cs.warningIcon()} ${cs.bold('workflow paused')} — ${cs.dim(EXTERNAL_CHANNEL_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 new file mode 100644 index 0000000000..4799303249 --- /dev/null +++ b/cli/src/commands/run/app/index.ts @@ -0,0 +1,63 @@ +import { Args, Flags } from '../../../framework/flags.js' +import { DifyCommand } from '../../_shared/dify-command.js' +import { httpRetryFlag } from '../../_shared/global-flags.js' +import { agentGuide } from './guide.js' +import { runApp } from './run.js' + +export default class RunApp extends DifyCommand { + static override description = 'Run an app and print the response' + + 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, repeatable)', default: [] }), + 'conversation': Flags.string({ description: 'Resume a chat conversation by id' }), + '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. Strips ... blocks silently by default; with --think, thinking is printed to stderr.', default: false }), + 'http-retry': httpRetryFlag, + 'output': Flags.string({ char: 'o', description: 'Output format (json|yaml|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, + }, + { bundle: ctx.bundle, 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/print-flags.ts b/cli/src/commands/run/app/print-flags.ts new file mode 100644 index 0000000000..a90d7b8f8c --- /dev/null +++ b/cli/src/commands/run/app/print-flags.ts @@ -0,0 +1,27 @@ +import type { PrintFlags } from '../../../printers/printer.js' +import type { StreamPrinter } from '../../../printers/stream-printer.js' +import { JsonYamlPrintFlags } from '../../../printers/format-json-yaml.js' +import { TextPrintFlags } from '../../../printers/format-text.js' +import { CompositePrintFlags } from '../../../printers/printer.js' +import { chatTextHandler, completionTextHandler, RUN_MODES, workflowTextHandler } from './handlers.js' +import { streamPrinterFor } from './stream-handlers.js' + +export class AppRunPrintFlags extends CompositePrintFlags { + private readonly jsonYaml = new JsonYamlPrintFlags() + private readonly text = new TextPrintFlags() + + constructor() { + super() + this.text.register(chatTextHandler, RUN_MODES.Chat, RUN_MODES.AgentChat, RUN_MODES.AdvancedChat) + this.text.register(completionTextHandler, RUN_MODES.Completion) + this.text.register(workflowTextHandler, RUN_MODES.Workflow) + } + + protected families(): readonly PrintFlags[] { + return [this.jsonYaml, this.text] + } + + toStreamPrinter(mode: string, think = false, isTTY = false): StreamPrinter { + return streamPrinterFor(mode, think, isTTY) + } +} diff --git a/cli/src/commands/run/app/run.test.ts b/cli/src/commands/run/app/run.test.ts new file mode 100644 index 0000000000..a778abc078 --- /dev/null +++ b/cli/src/commands/run/app/run.test.ts @@ -0,0 +1,358 @@ +import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js' +import type { HostsBundle } from '../../../auth/hosts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from '../../../../test/fixtures/dify-mock/server.js' +import { loadAppInfoCache } from '../../../cache/app-info.js' +import { createClient } from '../../../http/client.js' +import { bufferStreams } from '../../../io/streams.js' +import { resumeApp } from '../../resume/app/run.js' +import { runApp } from './run.js' + +function bundle(): HostsBundle { + return { + current_host: 'http://localhost', + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + account: { id: 'acct-1', email: 't@d.ai', name: 'T' }, + workspace: { id: 'ws-1', name: 'Default', role: 'owner' }, + available_workspaces: [ + { id: 'ws-1', name: 'Default', role: 'owner' }, + { id: 'ws-2', name: 'Other', role: 'normal' }, + ], + } +} + +describe('runApp', () => { + let mock: DifyMock + let dir: string + beforeEach(async () => { + mock = await startMock({ scenario: 'happy' }) + dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-')) + }) + afterEach(async () => { + 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({ configDir: dir }) + await runApp( + { appId: 'app-1', message: 'hi' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await expect(runApp( + { appId: 'app-2', message: 'hi' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-2', inputs: { x: '1' } }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-1', message: 'hi', format: 'json' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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' }, + { + bundle: bundle(), + http: createClient({ host: 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({ configDir: dir }) + await runApp( + { appId: 'app-1', message: 'hi', stream: true }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-1', message: 'hi', stream: true, format: 'json' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-4', workspace: 'ws-2', message: 'do research' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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('stream-error scenario: error event surfaces typed BaseError', async () => { + mock.setScenario('stream-error') + const io = bufferStreams() + const cache = await loadAppInfoCache({ configDir: dir }) + await expect(runApp( + { appId: 'app-1', message: 'hi', stream: true }, + { bundle: bundle(), http: createClient({ host: 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({ configDir: dir }) + 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 }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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 }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-2', inputsJson: '{"x":"hello"}' }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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 }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + let exitCode = -1 + await expect(runApp( + { appId: 'app-2', inputs: {} }, + { + bundle: bundle(), + http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + let exitCode = -1 + await expect(runApp( + { appId: 'app-2', inputs: {}, format: 'json' }, + { + bundle: bundle(), + http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await resumeApp( + { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await resumeApp( + { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await resumeApp( + { appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + await runApp( + { appId: 'app-2', files: ['doc=https://example.com/report.pdf'] }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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({ configDir: dir }) + const filePath = join(dir, 'test.pdf') + await writeFile(filePath, 'fake pdf content') + await runApp( + { appId: 'app-2', files: [`doc=@${filePath}`] }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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('workflow: --file overrides same-named key from --inputs (file wins)', async () => { + const io = bufferStreams() + const cache = await loadAppInfoCache({ configDir: dir }) + await runApp( + { appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] }, + { bundle: bundle(), http: createClient({ host: mock.url, bearer: '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') + }) +}) diff --git a/cli/src/commands/run/app/run.ts b/cli/src/commands/run/app/run.ts new file mode 100644 index 0000000000..d63787090e --- /dev/null +++ b/cli/src/commands/run/app/run.ts @@ -0,0 +1,117 @@ +import type { KyInstance } from 'ky' +import type { HostsBundle } from '../../../auth/hosts.js' +import type { AppInfoCache } from '../../../cache/app-info.js' +import type { IOStreams } from '../../../io/streams.js' +import { AppMetaClient } from '../../../api/app-meta.js' +import { AppRunClient } from '../../../api/app-run.js' +import { AppsClient } from '../../../api/apps.js' +import { FileUploadClient } from '../../../api/file-upload.js' +import { BaseError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { FieldInfo } from '../../../types/app-meta.js' +import { resolveWorkspaceId } from '../../../workspace/resolver.js' +import { pickStrategy } from './_strategies/index.js' +import { resolveFileInputs } from './file-flags.js' +import { RUN_MODES } from './handlers.js' +import { AppRunPrintFlags } from './print-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 +} + +export type RunAppDeps = { + readonly bundle: HostsBundle + readonly http: KyInstance + readonly host: string + readonly io: IOStreams + readonly cache?: AppInfoCache + readonly envLookup?: (k: string) => string | undefined + readonly exit?: (code: number) => never +} + +const TEXT_FORMATS = new Set(['', 'text']) + +async function resolveInputs( + inputsJson: string | undefined, + inputsFile: string | undefined, + directInputs: Readonly<Record<string, unknown>> | undefined, +): Promise<Record<string, unknown>> { + 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<string, unknown> + } + 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<string, unknown> + } + return { ...(directInputs ?? {}) } +} + +export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> { + const env = deps.envLookup ?? ((k: string) => process.env[k]) + const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle }) + const apps = new AppsClient(deps.http) + const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache }) + const m = await meta.get(opts.appId, wsId, [FieldInfo]) + const mode = m.info?.mode ?? '' + if (mode === '') + throw new Error(`app ${opts.appId}: mode missing from /describe`) + + 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 printFlags = new AppRunPrintFlags() + + const exit = deps.exit ?? ((code: number) => process.exit(code) as never) + const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, printFlags, 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 new file mode 100644 index 0000000000..e158599145 --- /dev/null +++ b/cli/src/commands/run/app/sse-collector.test.ts @@ -0,0 +1,243 @@ +import type { SseEvent } from '../../../http/sse.js' +import { describe, expect, it } from 'vitest' +import { collect, collectorFor, decodeStreamError, HitlPauseError } from './sse-collector.js' + +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 — 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 — 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.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 new file mode 100644 index 0000000000..d7a645227e --- /dev/null +++ b/cli/src/commands/run/app/sse-collector.ts @@ -0,0 +1,230 @@ +import type { BaseError } from '../../../errors/base.js' +import type { SseEvent } from '../../../http/sse.js' +import { newError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { RUN_MODES } from './handlers.js' + +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 + 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 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 + } + 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 + if (this.isAgent || this.thoughts.length > 0) + out.agent_thoughts = this.thoughts + return out + } +} + +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 + consume(ev: SseEvent): void { + if (ev.name !== 'workflow_finished') + return + this.final = parseJson(ev.data) + } + + finalize(): Record<string, unknown> { + return { mode: RUN_MODES.Workflow, ...(this.final ?? {}) } + } +} + +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 + let err = newError(code, message) + if (env.status !== undefined && env.status > 0) + err = 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 new file mode 100644 index 0000000000..fc584e1176 --- /dev/null +++ b/cli/src/commands/run/app/stream-handlers.test.ts @@ -0,0 +1,188 @@ +import type { SseEvent } from '../../../http/sse.js' +import { Buffer } from 'node:buffer' +import { PassThrough, Writable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { HitlPauseError } from './sse-collector.js' +import { streamPrinterFor } from './stream-handlers.js' + +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') + }) +}) + +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 — 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 new file mode 100644 index 0000000000..a54dbfe54b --- /dev/null +++ b/cli/src/commands/run/app/stream-handlers.ts @@ -0,0 +1,160 @@ +import type { SseEvent } from '../../../http/sse.js' +import type { StreamPrinter } from '../../../printers/stream-printer.js' +import type { HitlPausePayload } from './sse-collector.js' +import { newError } from '../../../errors/base.js' +import { ErrorCode } from '../../../errors/codes.js' +import { colorEnabled, colorScheme } from '../../../io/color.js' +import { ThinkChunkFilter } from '../../../io/think-filter.js' +import { RUN_MODES } from './handlers.js' +import { HitlPauseError } from './sse-collector.js' + +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 isTTY: boolean + constructor(think: boolean, isTTY = false) { + this.filter = new ThinkChunkFilter(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 + } + 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.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 + 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 + } + 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): void { + if (this.final === undefined) + return + const data = this.final.data + if (data !== null && typeof data === 'object' && 'outputs' in data) { + out.write(`${JSON.stringify((data as { outputs: unknown }).outputs)}\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(), +} + +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/tree.generated.ts b/cli/src/commands/tree.generated.ts new file mode 100644 index 0000000000..666884917c --- /dev/null +++ b/cli/src/commands/tree.generated.ts @@ -0,0 +1,87 @@ +// @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.js' +import AuthDevicesList from './auth/devices/list/index.js' +import AuthDevicesRevoke from './auth/devices/revoke/index.js' +import AuthLogin from './auth/login/index.js' +import AuthLogout from './auth/logout/index.js' +import AuthStatus from './auth/status/index.js' +import AuthUse from './auth/use/index.js' +import AuthWhoami from './auth/whoami/index.js' +import ConfigGet from './config/get/index.js' +import ConfigPath from './config/path/index.js' +import ConfigSet from './config/set/index.js' +import ConfigUnset from './config/unset/index.js' +import ConfigView from './config/view/index.js' +import DescribeApp from './describe/app/index.js' +import EnvList from './env/list/index.js' +import GetApp from './get/app/index.js' +import GetWorkspace from './get/workspace/index.js' +import HelpAccount from './help/account/index.js' +import HelpEnvironment from './help/environment/index.js' +import HelpExternal from './help/external/index.js' +import ResumeApp from './resume/app/index.js' +import RunApp from './run/app/index.js' +import Version from './version/index.js' + +export const commandTree: CommandTree = { + auth: { + subcommands: { + devices: { + subcommands: { + list: { command: AuthDevicesList, subcommands: {} }, + revoke: { command: AuthDevicesRevoke, subcommands: {} }, + }, + }, + login: { command: AuthLogin, subcommands: {} }, + logout: { command: AuthLogout, subcommands: {} }, + status: { command: AuthStatus, subcommands: {} }, + use: { command: AuthUse, 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: {} }, + }, + }, + describe: { + subcommands: { + app: { command: DescribeApp, subcommands: {} }, + }, + }, + env: { + subcommands: { + list: { command: EnvList, subcommands: {} }, + }, + }, + get: { + subcommands: { + app: { command: GetApp, subcommands: {} }, + workspace: { command: GetWorkspace, subcommands: {} }, + }, + }, + help: { + subcommands: { + account: { command: HelpAccount, subcommands: {} }, + environment: { command: HelpEnvironment, subcommands: {} }, + external: { command: HelpExternal, subcommands: {} }, + }, + }, + resume: { + subcommands: { + app: { command: ResumeApp, subcommands: {} }, + }, + }, + run: { + subcommands: { + app: { command: RunApp, subcommands: {} }, + }, + }, + version: { command: Version, subcommands: {} }, +} diff --git a/cli/src/commands/tree.ts b/cli/src/commands/tree.ts new file mode 100644 index 0000000000..14a480033f --- /dev/null +++ b/cli/src/commands/tree.ts @@ -0,0 +1 @@ +export { commandTree } from './tree.generated.js' diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts new file mode 100644 index 0000000000..b91169b1d4 --- /dev/null +++ b/cli/src/commands/version/index.ts @@ -0,0 +1,63 @@ +import { Flags } from '../../framework/flags.js' +import { formatted, raw, stringifyOutput } from '../../framework/output.js' +import { colorEnabled } from '../../io/color.js' +import { realStreams } from '../../io/streams.js' +import { versionInfo } from '../../version/info.js' +import { runVersionProbe } from '../../version/probe.js' +import { renderVersionText } from '../../version/render.js' +import { DifyCommand } from '../_shared/dify-command.js' + +export const COMPAT_FAIL_EXIT_CODE = 64 + +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', + ] + + static override flags = { + 'output': Flags.string({ + char: 'o', + description: 'output format (text|json|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'`, + }), + } + + 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. + process.stdout.write(stringifyOutput(output)) + this.error(report.compat.detail, { exit: COMPAT_FAIL_EXIT_CODE }) + } + + return output + } +} diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts new file mode 100644 index 0000000000..afd2346478 --- /dev/null +++ b/cli/src/commands/version/version.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as info from '../../version/info.js' +import * as probe from '../../version/probe.js' +import Version, { COMPAT_FAIL_EXIT_CODE } from './index.js' + +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(): ReturnType<typeof vi.spyOn<typeof process, 'exit'>> { + 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: 'unsupported' })) + 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: 'unsupported' })) + 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('unsupported') + 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 release candidate') + }) +}) diff --git a/cli/src/config/dir.test.ts b/cli/src/config/dir.test.ts new file mode 100644 index 0000000000..24ecde3986 --- /dev/null +++ b/cli/src/config/dir.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { DIR_PERM, FILE_PERM, resolveConfigDir } from './dir.js' + +function fakeEnv(opts: { + override?: string + xdg?: string + home?: string + appData?: string + platform: NodeJS.Platform +}) { + return { + getEnv: (name: string) => { + if (name === 'DIFY_CONFIG_DIR') + return opts.override + if (name === 'XDG_CONFIG_HOME') + return opts.xdg + return undefined + }, + homeDir: () => opts.home ?? '/home/u', + platform: () => opts.platform, + appData: () => opts.appData, + } +} + +describe('config dir', () => { + it('FILE_PERM is 0o600 + DIR_PERM is 0o700 (POSIX defaults)', () => { + expect(FILE_PERM).toBe(0o600) + expect(DIR_PERM).toBe(0o700) + }) + + it('DIFY_CONFIG_DIR override wins on every platform', () => { + for (const platform of ['linux', 'darwin', 'win32'] as const) { + expect(resolveConfigDir(fakeEnv({ override: '/tmp/x', platform }))) + .toBe('/tmp/x') + } + }) + + it('linux uses XDG_CONFIG_HOME when set', () => { + expect(resolveConfigDir(fakeEnv({ xdg: '/x', platform: 'linux' }))) + .toBe('/x/difyctl') + }) + + it('linux falls back to ~/.config when XDG unset', () => { + expect(resolveConfigDir(fakeEnv({ home: '/h', platform: 'linux' }))) + .toBe('/h/.config/difyctl') + }) + + it('linux ignores empty XDG_CONFIG_HOME', () => { + expect(resolveConfigDir(fakeEnv({ xdg: '', home: '/h', platform: 'linux' }))) + .toBe('/h/.config/difyctl') + }) + + it('macos uses ~/.config (not XDG, matches gh/kubectl)', () => { + expect(resolveConfigDir(fakeEnv({ xdg: '/ignored', home: '/h', platform: 'darwin' }))) + .toBe('/h/.config/difyctl') + }) + + it('windows uses APPDATA', () => { + expect(resolveConfigDir(fakeEnv({ appData: 'C:\\Users\\u\\AppData\\Roaming', platform: 'win32' }))) + .toMatch(/difyctl$/) + }) + + it('windows throws if APPDATA unresolvable', () => { + expect(() => resolveConfigDir(fakeEnv({ platform: 'win32' }))).toThrow(/APPDATA/) + }) + + it('unknown platform falls back to ~/.config', () => { + expect(resolveConfigDir(fakeEnv({ home: '/h', platform: 'freebsd' as NodeJS.Platform }))) + .toBe('/h/.config/difyctl') + }) +}) diff --git a/cli/src/config/dir.ts b/cli/src/config/dir.ts new file mode 100644 index 0000000000..6d92953769 --- /dev/null +++ b/cli/src/config/dir.ts @@ -0,0 +1,45 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' + +export const ENV_CONFIG_DIR = 'DIFY_CONFIG_DIR' +export const ENV_XDG_CONFIG_HOME = 'XDG_CONFIG_HOME' +export const SUBDIR = 'difyctl' +export const FILE_PERM = 0o600 +export const DIR_PERM = 0o700 + +export type ConfigEnvironment = { + readonly getEnv: (name: string) => string | undefined + readonly homeDir: () => string + readonly platform: () => NodeJS.Platform + readonly appData: () => string | undefined +} + +export const realEnvironment: ConfigEnvironment = { + getEnv: name => process.env[name], + homeDir: () => homedir(), + platform: () => process.platform, + appData: () => process.env.APPDATA ?? process.env.LOCALAPPDATA, +} + +export function resolveConfigDir(env: ConfigEnvironment = realEnvironment): string { + const override = env.getEnv(ENV_CONFIG_DIR) + if (override !== undefined && override !== '') + return override + + const platform = env.platform() + if (platform === 'linux') { + const xdg = env.getEnv(ENV_XDG_CONFIG_HOME) + if (xdg !== undefined && xdg !== '') + return join(xdg, SUBDIR) + return join(env.homeDir(), '.config', SUBDIR) + } + if (platform === 'darwin') + return join(env.homeDir(), '.config', SUBDIR) + if (platform === 'win32') { + const appData = env.appData() + if (appData === undefined || appData === '') + throw new Error('cannot resolve %APPDATA% on Windows') + return join(appData, SUBDIR) + } + return join(env.homeDir(), '.config', SUBDIR) +} diff --git a/cli/src/config/keys.test.ts b/cli/src/config/keys.test.ts new file mode 100644 index 0000000000..15a7aed72d --- /dev/null +++ b/cli/src/config/keys.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { isBaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { + getKey, + knownKeyNames, + knownKeys, + lookupKey, + setKey, + unsetKey, +} from './keys.js' +import { emptyConfig } from './schema.js' + +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 new file mode 100644 index 0000000000..4d719b1c4c --- /dev/null +++ b/cli/src/config/keys.ts @@ -0,0 +1,96 @@ +import type { AllowedFormat, ConfigFile } from './schema.js' +import { newError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { parseLimit } from '../limit/limit.js' +import { ALLOWED_FORMATS } from './schema.js' + +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/loader.test.ts b/cli/src/config/loader.test.ts new file mode 100644 index 0000000000..da7bac2c1f --- /dev/null +++ b/cli/src/config/loader.test.ts @@ -0,0 +1,87 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { isBaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { loadConfig } from './loader.js' +import { FILE_NAME } from './schema.js' + +describe('loadConfig', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-')) + }) + + afterEach(async () => { + await mkdir(dir, { recursive: true }).catch(() => {}) + }) + + it('returns found:false when config.yml is missing', async () => { + const r = await loadConfig(dir) + expect(r.found).toBe(false) + }) + + it('parses a minimal valid config.yml', async () => { + await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8') + const r = await loadConfig(dir) + expect(r.found).toBe(true) + if (r.found) + expect(r.config.schema_version).toBe(1) + }) + + it('parses defaults + state', async () => { + await writeFile( + join(dir, FILE_NAME), + 'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n', + 'utf8', + ) + const r = await loadConfig(dir) + 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 YAML is malformed', async () => { + await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8') + let caught: unknown + try { + await loadConfig(dir) + } + 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 zod validation fails', async () => { + await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8') + let caught: unknown + try { + await loadConfig(dir) + } + 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 writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8') + let caught: unknown + try { + await loadConfig(dir) + } + 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/loader.ts b/cli/src/config/loader.ts new file mode 100644 index 0000000000..8ff00b3631 --- /dev/null +++ b/cli/src/config/loader.ts @@ -0,0 +1,58 @@ +import type { ConfigFile } from './schema.js' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { load as parseYaml } from 'js-yaml' +import { newError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { + + ConfigFileSchema, + CURRENT_SCHEMA_VERSION, + FILE_NAME, +} from './schema.js' + +export type LoadResult + = | { found: false } + | { found: true, config: ConfigFile } + +export async function loadConfig(dir: string): Promise<LoadResult> { + const path = join(dir, FILE_NAME) + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return { found: false } + throw newError(ErrorCode.Unknown, `read ${path}: ${(err as Error).message}`) + .wrap(err) + } + + let parsed: unknown + try { + parsed = parseYaml(raw) + } + catch (err) { + throw newError( + ErrorCode.ConfigSchemaUnsupported, + `parse ${path}: ${(err as Error).message}`, + ).wrap(err).withHint('config.yml is not valid YAML') + } + + const result = ConfigFileSchema.safeParse(parsed ?? {}) + if (!result.success) { + throw newError( + ErrorCode.ConfigSchemaUnsupported, + `validate ${path}: ${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/schema.test.ts b/cli/src/config/schema.test.ts new file mode 100644 index 0000000000..8fec34dec5 --- /dev/null +++ b/cli/src/config/schema.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + ALLOWED_FORMATS, + ConfigFileSchema, + CURRENT_SCHEMA_VERSION, + emptyConfig, + FILE_NAME, +} from './schema.js' + +describe('config schema', () => { + it('CURRENT_SCHEMA_VERSION is 1', () => { + expect(CURRENT_SCHEMA_VERSION).toBe(1) + }) + + it('FILE_NAME is config.yml', () => { + expect(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 new file mode 100644 index 0000000000..f5946bfc31 --- /dev/null +++ b/cli/src/config/schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod' + +export const CURRENT_SCHEMA_VERSION = 1 +export const FILE_NAME = 'config.yml' + +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/config/writer.test.ts b/cli/src/config/writer.test.ts new file mode 100644 index 0000000000..0fb08f70de --- /dev/null +++ b/cli/src/config/writer.test.ts @@ -0,0 +1,80 @@ +import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it } from 'vitest' +import { loadConfig } from './loader.js' +import { emptyConfig, FILE_NAME } from './schema.js' +import { saveConfig } from './writer.js' + +describe('saveConfig', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-w-')) + }) + + it('writes config.yml in the target dir', async () => { + await saveConfig(dir, { ...emptyConfig(), schema_version: 1 }) + const stats = await stat(join(dir, FILE_NAME)) + expect(stats.isFile()).toBe(true) + }) + + it('stamps schema_version=1 even if caller passed 0', async () => { + await saveConfig(dir, { ...emptyConfig() }) + const r = await loadConfig(dir) + expect(r.found).toBe(true) + if (r.found) + expect(r.config.schema_version).toBe(1) + }) + + it('round-trips defaults + state through YAML', async () => { + await saveConfig(dir, { + schema_version: 1, + defaults: { format: 'wide', limit: 75 }, + state: { current_app: 'app-xyz' }, + }) + const r = await loadConfig(dir) + 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('writes file with mode 0o600 (POSIX)', async () => { + if (process.platform === 'win32') + return + await saveConfig(dir, emptyConfig()) + const s = await stat(join(dir, FILE_NAME)) + expect(s.mode & 0o777).toBe(0o600) + }) + + it('does not leave a tmp file on success', async () => { + await saveConfig(dir, emptyConfig()) + const entries = await readdir(dir) + expect(entries.filter(f => f.endsWith('.tmp'))).toHaveLength(0) + expect(entries.filter(f => f.includes('.tmp.'))).toHaveLength(0) + }) + + it('creates parent dir at 0o700 if absent', async () => { + if (process.platform === 'win32') + return + const nested = join(dir, 'nested', 'sub') + await saveConfig(nested, emptyConfig()) + const s = await stat(nested) + expect(s.isDirectory()).toBe(true) + expect(s.mode & 0o777).toBe(0o700) + }) + + it('emits parseable YAML (round-trip via fs.readFile + js-yaml)', async () => { + await saveConfig(dir, { + schema_version: 1, + defaults: { format: 'json' }, + state: {}, + }) + const raw = await readFile(join(dir, FILE_NAME), 'utf8') + expect(raw).toMatch(/^schema_version:/m) + expect(raw).toMatch(/format: json/) + }) +}) diff --git a/cli/src/config/writer.ts b/cli/src/config/writer.ts new file mode 100644 index 0000000000..8362ebf884 --- /dev/null +++ b/cli/src/config/writer.ts @@ -0,0 +1,39 @@ +import type { ConfigFile } from './schema.js' +import { mkdir, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { dump as dumpYaml } from 'js-yaml' +import { newError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { DIR_PERM, FILE_PERM } from './dir.js' +import { + + CURRENT_SCHEMA_VERSION, + FILE_NAME, +} from './schema.js' + +export async function saveConfig(dir: string, config: ConfigFile): Promise<void> { + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + + const stamped: ConfigFile = { ...config, schema_version: CURRENT_SCHEMA_VERSION } + const yaml = dumpYaml(stamped, { lineWidth: -1, noRefs: true }) + + const target = join(dir, FILE_NAME) + const tmp = `${target}.tmp.${process.pid}.${Date.now()}` + + try { + await writeFile(tmp, yaml, { mode: FILE_PERM }) + await rename(tmp, target) + } + catch (err) { + try { + await unlink(tmp) + } + catch { + // tmp may not exist if writeFile failed before creating it + } + throw newError( + ErrorCode.Unknown, + `save ${target}: ${(err as Error).message}`, + ).wrap(err) + } +} diff --git a/cli/src/env/registry.test.ts b/cli/src/env/registry.test.ts new file mode 100644 index 0000000000..5ea4726d6f --- /dev/null +++ b/cli/src/env/registry.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + ENV_REGISTRY, + getEnv, + lookupEnv, + resolveEnv, +} from './registry.js' + +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 new file mode 100644 index 0000000000..5a7938e01b --- /dev/null +++ b/cli/src/env/registry.ts @@ -0,0 +1,68 @@ +import { parseLimit } from '../limit/limit.js' + +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 function getEnv(name: string): string | undefined { + return process.env[name] +} + +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 new file mode 100644 index 0000000000..196646fae4 --- /dev/null +++ b/cli/src/errors/base.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { BaseError, isBaseError, newError, unknownError } from './base.js' +import { ErrorCode, ExitCode } from './codes.js' + +describe('BaseError', () => { + it('captures code, message, optional fields', () => { + const err = new BaseError({ + code: ErrorCode.AuthExpired, + message: 'session expired', + hint: 'run difyctl auth login', + httpStatus: 401, + method: 'GET', + url: 'https://x/y', + }) + expect(err.code).toBe(ErrorCode.AuthExpired) + expect(err.message).toBe('session expired') + expect(err.hint).toBe('run difyctl auth login') + expect(err.httpStatus).toBe(401) + expect(err.method).toBe('GET') + expect(err.url).toBe('https://x/y') + }) + + it('is an Error instance and instanceof BaseError', () => { + const err = newError(ErrorCode.Unknown, 'x') + expect(err).toBeInstanceOf(Error) + expect(err).toBeInstanceOf(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.NetworkDns, 'x').exit()).toBe(ExitCode.Generic) + }) + + it('toString without hint formats "<code>: <message>"', () => { + const err = newError(ErrorCode.AuthExpired, 'session expired') + expect(err.toString()).toBe('auth_expired: session expired') + }) + + it('toString with hint formats "<code>: <message> (hint: <hint>)"', () => { + const err = newError(ErrorCode.AuthExpired, 'session expired') + .withHint('run \'difyctl auth login\'') + expect(err.toString()).toBe( + 'auth_expired: session expired (hint: run \'difyctl auth login\')', + ) + }) + + it('builder methods return new instances; original unchanged', () => { + const original = newError(ErrorCode.Unknown, 'boom') + const hinted = original.withHint('try again') + expect(original.hint).toBeUndefined() + expect(hinted.hint).toBe('try again') + expect(hinted).not.toBe(original) + }) + + it('withHttpStatus + withRequest + wrap chain immutably', () => { + const cause = new Error('underlying') + const built = newError(ErrorCode.NetworkTimeout, 'timed out') + .withHttpStatus(504) + .withRequest('POST', 'https://x/y') + .wrap(cause) + expect(built.httpStatus).toBe(504) + expect(built.method).toBe('POST') + expect(built.url).toBe('https://x/y') + expect(built.cause).toBe(cause) + }) + + it('wrap exposes cause via standard Error.cause property', () => { + const cause = new Error('underlying failure') + const wrapped = newError(ErrorCode.NetworkTimeout, 'timed out').wrap(cause) + expect(wrapped.cause).toBe(cause) + }) + + it('isBaseError narrows unknown values', () => { + expect(isBaseError(newError(ErrorCode.Unknown, 'x'))).toBe(true) + expect(isBaseError(new Error('plain'))).toBe(false) + expect(isBaseError({ code: 'unknown' })).toBe(false) + expect(isBaseError(undefined)).toBe(false) + }) + + it('unknownError factory wraps cause and uses ErrorCode.Unknown', () => { + const cause = new Error('boom') + const err = unknownError('something failed', cause) + expect(err.code).toBe(ErrorCode.Unknown) + expect(err.cause).toBe(cause) + }) +}) diff --git a/cli/src/errors/base.ts b/cli/src/errors/base.ts new file mode 100644 index 0000000000..3ec8b6e44f --- /dev/null +++ b/cli/src/errors/base.ts @@ -0,0 +1,81 @@ +import type { ErrorCodeValue, ExitCodeValue } from './codes.js' +import { ErrorCode, exitFor } from './codes.js' + +export type BaseErrorOptions = { + readonly code: ErrorCodeValue + readonly message: string + readonly hint?: string + readonly httpStatus?: number + readonly method?: string + readonly url?: string + readonly cause?: unknown +} + +export class BaseError extends Error { + readonly code: ErrorCodeValue + readonly hint?: string + readonly httpStatus?: number + readonly method?: string + readonly url?: string + + 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.httpStatus = opts.httpStatus + this.method = opts.method + this.url = opts.url + Object.setPrototypeOf(this, new.target.prototype) + } + + exit(): ExitCodeValue { + return exitFor(this.code) + } + + override toString(): string { + return this.hint + ? `${this.code}: ${this.message} (hint: ${this.hint})` + : `${this.code}: ${this.message}` + } + + withHint(hint: string): BaseError { + return new BaseError({ ...this.snapshot(), hint }) + } + + withHttpStatus(httpStatus: number): BaseError { + return new BaseError({ ...this.snapshot(), httpStatus }) + } + + withRequest(method: string, url: string): BaseError { + return new BaseError({ ...this.snapshot(), method, url }) + } + + wrap(cause: unknown): BaseError { + return new BaseError({ ...this.snapshot(), cause }) + } + + private snapshot(): BaseErrorOptions { + return { + code: this.code, + message: this.message, + hint: this.hint, + httpStatus: this.httpStatus, + method: this.method, + url: this.url, + cause: this.cause, + } + } +} + +export function newError(code: ErrorCodeValue, message: string): BaseError { + return new BaseError({ code, message }) +} + +export function isBaseError(value: unknown): value is BaseError { + return value instanceof BaseError +} + +export function unknownError(message: string, cause?: unknown): BaseError { + return new BaseError({ code: ErrorCode.Unknown, message, cause }) +} diff --git a/cli/src/errors/codes.test.ts b/cli/src/errors/codes.test.ts new file mode 100644 index 0000000000..c89aa41d50 --- /dev/null +++ b/cli/src/errors/codes.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + ALL_ERROR_CODES, + CODE_TO_EXIT_MAP, + ErrorCode, + ExitCode, + exitFor, +} from './codes.js' + +describe('error codes', () => { + it('has 17 codes (parity with internal/api/errors)', () => { + expect(ALL_ERROR_CODES).toHaveLength(17) + }) + + 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) + }) + + 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.NetworkTimeout, ExitCode.Generic], + [ErrorCode.NetworkDns, ExitCode.Generic], + [ErrorCode.Server5xx, ExitCode.Generic], + [ErrorCode.Server4xxOther, ExitCode.Generic], + [ErrorCode.Unknown, ExitCode.Generic], + ])('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) + }) +}) diff --git a/cli/src/errors/codes.ts b/cli/src/errors/codes.ts new file mode 100644 index 0000000000..ad2a1089ce --- /dev/null +++ b/cli/src/errors/codes.ts @@ -0,0 +1,58 @@ +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', + ConfigInvalidKey: 'config_invalid_key', + ConfigInvalidValue: 'config_invalid_value', + NetworkTimeout: 'network_timeout', + NetworkDns: 'network_dns', + Server5xx: 'server_5xx', + Server4xxOther: 'server_4xx_other', + Unknown: 'unknown', +} as const + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode] + +export const ExitCode = { + Success: 0, + Generic: 1, + Usage: 2, + Auth: 4, + VersionCompat: 6, +} as const + +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, + config_invalid_key: ExitCode.Usage, + config_invalid_value: ExitCode.Usage, + network_timeout: ExitCode.Generic, + network_dns: ExitCode.Generic, + server_5xx: ExitCode.Generic, + server_4xx_other: ExitCode.Generic, + unknown: ExitCode.Generic, +} + +export function exitFor(code: string): ExitCodeValue { + return (CODE_TO_EXIT as Record<string, ExitCodeValue>)[code] ?? ExitCode.Generic +} + +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 0000000000..8f736faa46 --- /dev/null +++ b/cli/src/errors/envelope.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { newError } from './base.js' +import { ErrorCode } from './codes.js' +import { renderEnvelope, toEnvelope } from './envelope.js' + +describe('error envelope', () => { + it('emits required fields only when minimal', () => { + const err = newError(ErrorCode.Unknown, 'boom') + expect(toEnvelope(err)).toEqual({ + error: { code: 'unknown', message: 'boom' }, + }) + }) + + it('includes hint / http_status / method / url when present', () => { + const err = newError(ErrorCode.NetworkTimeout, 'timed out') + .withHint('check your network') + .withHttpStatus(504) + .withRequest('POST', 'https://api.dify.ai/v1/x') + expect(toEnvelope(err)).toEqual({ + error: { + code: 'network_timeout', + message: 'timed out', + hint: 'check your network', + http_status: 504, + method: 'POST', + url: 'https://api.dify.ai/v1/x', + }, + }) + }) + + it('renderEnvelope returns a single-line JSON string', () => { + const err = newError(ErrorCode.AuthExpired, 'session expired') + .withHint('run difyctl auth login') + const out = renderEnvelope(err) + expect(out).toBe( + '{"error":{"code":"auth_expired","message":"session expired","hint":"run difyctl auth login"}}', + ) + expect(out).not.toContain('\n') + }) + + it('renderEnvelope output round-trips through JSON.parse to an ErrorEnvelope shape', () => { + const err = newError(ErrorCode.UsageInvalidFlag, 'bad flag').withHint('see --help') + const parsed = JSON.parse(renderEnvelope(err)) + expect(parsed).toEqual({ + error: { code: 'usage_invalid_flag', message: 'bad flag', hint: 'see --help' }, + }) + }) + + it('omits undefined optional fields entirely (no `hint: null`)', () => { + const err = newError(ErrorCode.Server5xx, 'upstream broke') + const envelope = toEnvelope(err) + expect(envelope.error).not.toHaveProperty('hint') + expect(envelope.error).not.toHaveProperty('http_status') + expect(envelope.error).not.toHaveProperty('method') + expect(envelope.error).not.toHaveProperty('url') + }) +}) diff --git a/cli/src/errors/envelope.ts b/cli/src/errors/envelope.ts new file mode 100644 index 0000000000..e817890606 --- /dev/null +++ b/cli/src/errors/envelope.ts @@ -0,0 +1,32 @@ +import type { BaseError } from './base.js' + +export type ErrorEnvelope = { + error: { + code: string + message: string + hint?: string + http_status?: number + method?: string + url?: string + } +} + +export function toEnvelope(err: BaseError): ErrorEnvelope { + const payload: ErrorEnvelope['error'] = { + code: err.code, + message: err.message, + } + if (err.hint !== undefined) + payload.hint = err.hint + if (err.httpStatus !== undefined) + payload.http_status = err.httpStatus + if (err.method !== undefined) + payload.method = err.method + if (err.url !== undefined) + payload.url = err.url + return { error: payload } +} + +export function renderEnvelope(err: BaseError): string { + return JSON.stringify(toEnvelope(err)) +} diff --git a/cli/src/errors/format.ts b/cli/src/errors/format.ts new file mode 100644 index 0000000000..a65b466f56 --- /dev/null +++ b/cli/src/errors/format.ts @@ -0,0 +1,26 @@ +import type { BaseError } from './base.js' +import { colorEnabled, colorScheme } from '../io/color.js' +import { renderEnvelope } from './envelope.js' + +export type FormatErrorOptions = { + readonly format?: string + readonly isErrTTY?: boolean +} + +export function formatErrorForCli(err: BaseError, opts: FormatErrorOptions = {}): string { + if (opts.format === 'json') + return renderEnvelope(err) + return humanError(err, opts.isErrTTY ?? false) +} + +function humanError(err: BaseError, isErrTTY: boolean): string { + const cs = colorScheme(colorEnabled(isErrTTY)) + const lines: string[] = [`${err.code}: ${err.message}`] + if (err.hint !== undefined) + lines.push(`${cs.magenta('hint:')} ${cs.cyan(err.hint)}`) + if (err.method !== undefined && err.url !== undefined) + lines.push(`request: ${err.method} ${err.url}`) + if (err.httpStatus !== undefined) + lines.push(`http_status: ${err.httpStatus}`) + return lines.join('\n') +} diff --git a/cli/src/framework/command-fs.test.ts b/cli/src/framework/command-fs.test.ts new file mode 100644 index 0000000000..9d35746164 --- /dev/null +++ b/cli/src/framework/command-fs.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { isCommandIndexPath, isExcludedCommandPath } from './command-fs.js' + +describe('isExcludedCommandPath', () => { + it('excludes any path with an underscore-prefixed segment', () => { + expect(isExcludedCommandPath('auth/_shared/foo.ts')).toBe(true) + expect(isExcludedCommandPath('run/app/_strategies/index.ts')).toBe(true) + expect(isExcludedCommandPath('auth/devices/_shared/util.ts')).toBe(true) + expect(isExcludedCommandPath('_shared/index.ts')).toBe(true) + }) + + it('keeps regular paths', () => { + expect(isExcludedCommandPath('auth/login/index.ts')).toBe(false) + expect(isExcludedCommandPath('version/index.ts')).toBe(false) + }) + + it('normalizes backslashes', () => { + expect(isExcludedCommandPath('run\\app\\_strategies\\index.ts')).toBe(true) + }) +}) + +describe('isCommandIndexPath', () => { + it('accepts paths that end with /index.ts and contain no excluded segments', () => { + expect(isCommandIndexPath('auth/login/index.ts')).toBe(true) + expect(isCommandIndexPath('version/index.ts')).toBe(true) + }) + + it('rejects non-index.ts files', () => { + expect(isCommandIndexPath('auth/login/util.ts')).toBe(false) + }) + + it('rejects excluded paths', () => { + expect(isCommandIndexPath('_shared/index.ts')).toBe(false) + expect(isCommandIndexPath('run/app/_strategies/index.ts')).toBe(false) + }) +}) diff --git a/cli/src/framework/command-fs.ts b/cli/src/framework/command-fs.ts new file mode 100644 index 0000000000..6fb5aca6be --- /dev/null +++ b/cli/src/framework/command-fs.ts @@ -0,0 +1,14 @@ +function normalize(p: string): string { + return p.replace(/\\/g, '/') +} + +export function isExcludedCommandPath(relPath: string): boolean { + return normalize(relPath).split('/').some(seg => seg.startsWith('_')) +} + +export function isCommandIndexPath(relPath: string): boolean { + const n = normalize(relPath) + if (!n.endsWith('/index.ts')) + return false + return !isExcludedCommandPath(n) +} diff --git a/cli/src/framework/command.ts b/cli/src/framework/command.ts new file mode 100644 index 0000000000..be5d4a1ec3 --- /dev/null +++ b/cli/src/framework/command.ts @@ -0,0 +1,53 @@ +import type { CommandOutput } from './output.js' +import type { ArgDefinition, FlagDefinition, ICommand, InferArgs, InferFlags, OptionalArgValueType } from './types.js' +import { parseArgv } from './flags.js' + +export type CommandConstructor = { + new(): Command + description?: string + flags?: Record<string, FlagDefinition<OptionalArgValueType>> + args?: Record<string, ArgDefinition<string | undefined>> + examples?: string[] + hidden?: boolean + deprecated?: string +} + +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[] = [] + + abstract run(argv: string[]): Promise<CommandOutput | void> + + 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/flags.test.ts b/cli/src/framework/flags.test.ts new file mode 100644 index 0000000000..efab143a06 --- /dev/null +++ b/cli/src/framework/flags.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest' +import { Args, Flags, parseArgv } from './flags.js' + +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( + '--mode must be one of: chat, workflow, completion', + ) + }) + + it('rejects an invalid option value (= form)', () => { + expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow( + '--mode must be one of: chat, workflow, completion', + ) + }) + }) + + 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 new file mode 100644 index 0000000000..c5a3baa583 --- /dev/null +++ b/cli/src/framework/flags.ts @@ -0,0 +1,193 @@ +import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types.js' + +function stringFlag<const Opts extends { description: string, char?: string, default?: string, multiple?: boolean, helpGroup?: string, options?: readonly string[] }>( + opts: Opts, +): FlagDefinition<string> { + return { + type: 'string', + multiple: false, + ...opts, + } +} + +function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean, helpGroup?: string }>( + opts: Opts, +): FlagDefinition<string[]> { + return { + type: 'string', + multiple: true, + ...opts, + } +} + +function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: string }): FlagDefinition<boolean> { + return { type: 'boolean', ...opts } +} + +function integerFlag<const Opts extends { description: string, char?: string, default?: number, helpGroup?: string }>( + opts: Opts, +): FlagDefinition<Opts extends { default: number } ? number : number | undefined> { + return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined> +} + +export const Flags = { + string: stringFlag, + stringArray: stringRepeatedFlag, + boolean: booleanFlag, + integer: integerFlag, +} + +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, meta: CommandMeta): [name: string, def: FlagDefinition] | undefined { + for (const [name, def] of Object.entries(meta.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 Error(`--${name} must be one of: ${def.options.join(', ')}`) +} + +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('--')) { + const eqIdx = token.indexOf('=') + let name: string + let rawValue: string | undefined + + if (eqIdx !== -1) { + name = token.slice(2, eqIdx) + rawValue = token.slice(eqIdx + 1) + } + else { + name = token.slice(2) + rawValue = undefined + } + + const def = meta.flags[name] + if (!def) + throw new Error(`unknown flag: --${name}`) + + if (def.type === 'boolean') { + flags[name] = rawValue === undefined ? true : coerceFlagValue(rawValue, def) + } + else if (rawValue !== undefined) { + validateFlagOptions(name, rawValue, def) + accumulateFlagValue(flags, name, coerceFlagValue(rawValue, def), def) + } + else { + i++ + const next = i < argv.length ? argv[i] : undefined + if (next === undefined || next.startsWith('-')) + throw new Error(`flag --${name} expects a value`) + + validateFlagOptions(name, next, def) + accumulateFlagValue(flags, name, coerceFlagValue(next, def), def) + } + } + else if (!pastDoubleDash && token.startsWith('-') && token.length === 2 && token[1] !== undefined) { + const char = token[1] + const resolved = resolveByChar(char, meta) + if (!resolved) + throw new Error(`unknown flag: -${char}`) + + const [flagName, def] = resolved + if (def.type === 'boolean') { + flags[flagName] = true + } + else { + i++ + const next = i < argv.length ? argv[i] : undefined + if (next === undefined || next.startsWith('-')) + throw new Error(`flag -${char} expects a value`) + + accumulateFlagValue(flags, flagName, coerceFlagValue(next, def), def) + } + } + else { + positional.push(token) + } + } + + 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 new file mode 100644 index 0000000000..6cbcd3fada --- /dev/null +++ b/cli/src/framework/help.test.ts @@ -0,0 +1,136 @@ +import type { CommandConstructor } from './command.js' +import { describe, expect, it } from 'vitest' +import { Args, Flags } from './flags.js' +import { formatHelp } from './help.js' + +function makeCmd(opts: { + description?: string + flags?: CommandConstructor['flags'] + args?: CommandConstructor['args'] + examples?: string[] + agentGuide?: string +}): CommandConstructor { + class Cmd { + static description = opts.description + static flags = opts.flags ?? {} + static args = opts.args ?? {} + static examples = opts.examples ?? [] + 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') + }) +}) diff --git a/cli/src/framework/help.ts b/cli/src/framework/help.ts new file mode 100644 index 0000000000..a5f6127471 --- /dev/null +++ b/cli/src/framework/help.ts @@ -0,0 +1,72 @@ +import type { CommandConstructor } from './command.js' +import type { FlagDefinition } from './types.js' + +function flagLabel(name: string, def: FlagDefinition): string { + const parts: string[] = [] + + if (def.char) + parts.push(`-${def.char}`) + + parts.push(`--${name}`) + + if (def.type !== 'boolean') + parts.push(`<${def.type}>`) + + return parts.join(', ') +} + +function flagDefault(def: FlagDefinition): string { + if (def.default === undefined) + return '' + + return ` [default: ${JSON.stringify(def.default)}]` +} + +export function formatHelp(ctor: CommandConstructor, path: string): string { + const lines: string[] = [] + const bin = 'difyctl' + + 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 ctor.examples) { + lines.push(` $ ${ex.replace('<%= config.bin %>', bin)}`) + } + + lines.push('') + } + + const C = ctor + const guide = ((new C())).agentGuide() + + if (typeof guide === 'string' && guide.length > 0) + lines.push(guide) + + return lines.join('\n') +} diff --git a/cli/src/framework/output.test.ts b/cli/src/framework/output.test.ts new file mode 100644 index 0000000000..accd8328b5 --- /dev/null +++ b/cli/src/framework/output.test.ts @@ -0,0 +1,204 @@ +import type { FormattedPrintable, NamePrintable, TablePrintable } from './output.js' +import { describe, expect, it } from 'vitest' +import { + formatted, + raw, + stringifyOutput, + table, +} from './output.js' + +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('name output requires data.name()') + }) + + it('unknown format: throws with allowed list', () => { + const out = formatted({ format: 'csv', data: makeFormatted({}) }) + expect(() => stringifyOutput(out)).toThrow(/not supported/) + expect(() => stringifyOutput(out)).toThrow(/json, name, text, yaml/) + }) +}) + +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('name output requires data.name()') + }) + + it('unknown format: throws with allowed list', () => { + const out = table({ format: 'csv', data: makeTable({}) }) + expect(() => stringifyOutput(out)).toThrow(/not supported/) + expect(() => stringifyOutput(out)).toThrow(/json, name, wide, yaml/) + }) + + 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 new file mode 100644 index 0000000000..7f56c1b1e2 --- /dev/null +++ b/cli/src/framework/output.ts @@ -0,0 +1,195 @@ +import yaml from 'js-yaml' + +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 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 'text': + return output.data.text() + case 'json': + return `${JSON.stringify(output.data.json(), null, 2)}\n` + case 'yaml': + return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 }) + case 'name': + return `${toName(output.data)}\n` + default: + throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, text, yaml`) + } +} + +function stringifyTableOutput(output: TableOutput<TablePrintable>): string { + switch (output.format) { + case '': + case 'wide': + return renderTable(output) + case 'json': + return `${JSON.stringify(output.data.json(), null, 2)}\n` + case 'yaml': + return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 }) + case 'name': + return `${toName(output.data)}\n` + default: + throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, wide, yaml`) + } +} + +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 Error('name output requires data.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/registry.test.ts b/cli/src/framework/registry.test.ts new file mode 100644 index 0000000000..a4a4302884 --- /dev/null +++ b/cli/src/framework/registry.test.ts @@ -0,0 +1,141 @@ +import type { CommandTree } from './registry.js' +import { describe, expect, it } from 'vitest' +import { Command } from './command.js' +import { findSuggestions, resolveCommand } from './registry.js' + +class FooCmd extends Command { + async run(_argv: string[]) {} +} +class FooBarCmd extends Command { + async run(_argv: string[]) {} +} +class FooBazCmd extends Command { + async run(_argv: string[]) {} +} +class TopLevelCmd extends Command { + async run(_argv: string[]) {} +} + +const tree: CommandTree = { + foo: { + command: undefined, + subcommands: { + bar: { command: FooBarCmd, subcommands: {} }, + baz: { command: FooBazCmd, subcommands: {} }, + }, + }, + top: { + command: TopLevelCmd, + subcommands: {}, + }, + nested: { + command: FooCmd, + subcommands: { + deep: { command: FooBarCmd, subcommands: {} }, + }, + }, +} + +describe('resolveCommand', () => { + it('resolves a leaf subcommand', () => { + const result = resolveCommand(tree, ['foo', 'bar']) + expect(result?.command).toBe(FooBarCmd) + expect(result?.path).toEqual(['foo', 'bar']) + }) + + it('resolves another leaf subcommand', () => { + const result = resolveCommand(tree, ['foo', 'baz']) + expect(result?.command).toBe(FooBazCmd) + expect(result?.path).toEqual(['foo', 'baz']) + }) + + it('resolves a top-level command', () => { + const result = resolveCommand(tree, ['top']) + expect(result?.command).toBe(TopLevelCmd) + expect(result?.path).toEqual(['top']) + }) + + it('returns undefined for unknown top-level token', () => { + const result = resolveCommand(tree, ['unknown']) + expect(result).toBeUndefined() + }) + + it('returns undefined for unknown subcommand', () => { + const result = resolveCommand(tree, ['foo', 'nope']) + expect(result).toBeUndefined() + }) + + it('returns undefined for empty argv', () => { + const result = resolveCommand(tree, []) + expect(result).toBeUndefined() + }) + + it('ignores flag tokens during path traversal', () => { + const result = resolveCommand(tree, ['foo', '--verbose', 'bar']) + expect(result).toBeUndefined() + }) + + it('strips remaining argv (flags/args) from path', () => { + const result = resolveCommand(tree, ['foo', 'bar', '--output', 'json']) + expect(result?.path).toEqual(['foo', 'bar']) + }) + + it('prefers deeper subcommand over parent command', () => { + const result = resolveCommand(tree, ['nested', 'deep']) + expect(result?.command).toBe(FooBarCmd) + expect(result?.path).toEqual(['nested', 'deep']) + }) + + it('resolves parent command when subcommand token is not in subcommands', () => { + const result = resolveCommand(tree, ['nested', 'unknown']) + expect(result?.command).toBe(FooCmd) + expect(result?.path).toEqual(['nested']) + }) +}) + +describe('findSuggestions', () => { + it('returns empty for token with edit distance > 1 to all commands', () => { + const suggestions = findSuggestions(tree, ['xyz']) + expect(suggestions).toHaveLength(0) + }) + + it('suggests softly matched top-level command', () => { + const suggestions = findSuggestions(tree, ['tpp']) + expect(suggestions).toEqual(['top']) + }) + + it('suggests softly matched subcommand under exact parent', () => { + const suggestions = findSuggestions(tree, ['foo', 'br']) + expect(suggestions).toEqual(['foo bar']) + }) + + it('returns all subcommands when multiple soft-match at the same level', () => { + const suggestions = findSuggestions(tree, ['foo', 'bax']) + expect(suggestions).toEqual(expect.arrayContaining(['foo bar', 'foo baz'])) + }) + + it('collects leaf command when trailing positional args remain', () => { + const suggestions = findSuggestions(tree, ['foo', 'br', 'some-arg']) + expect(suggestions).toEqual(['foo bar']) + }) + + it('returns empty when subcommand token has edit distance > 1', () => { + const suggestions = findSuggestions(tree, ['foo', 'unknown']) + expect(suggestions).toHaveLength(0) + }) + + it('returns available subcommands when given valid parent with no further tokens', () => { + const suggestions = findSuggestions(tree, ['foo']) + expect(suggestions).toEqual(expect.arrayContaining(['foo bar', 'foo baz'])) + }) + + it('collects exact-matched leaf with trailing tokens as positional args', () => { + const suggestions = findSuggestions(tree, ['top', 'sub', 'unknown']) + expect(suggestions).toEqual(['top']) + }) + + it('stops at flag token', () => { + const suggestions = findSuggestions(tree, ['--help']) + expect(suggestions).toHaveLength(0) + }) +}) diff --git a/cli/src/framework/registry.ts b/cli/src/framework/registry.ts new file mode 100644 index 0000000000..76733392b4 --- /dev/null +++ b/cli/src/framework/registry.ts @@ -0,0 +1,93 @@ +import type { CommandConstructor } from './command.js' + +export type CommandNode = { + readonly command?: CommandConstructor + readonly subcommands: Record<string, CommandNode> +} + +export type CommandTree = Record<string, CommandNode> + +function buildPath(parts: string[]): string { + return parts.join(' ') +} + +export function resolveCommand( + tree: CommandTree, + argv: string[], +): { command: CommandConstructor, path: string[] } | undefined { + const path: string[] = [] + let node: CommandNode | undefined + let lastMatch: { command: CommandConstructor, path: string[] } | 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 + + node = next + path.push(token) + + if (node.command) { + lastMatch = { command: node.command, path: [...path] } + const nextToken = argv[i + 1] + if (nextToken === undefined || nextToken.startsWith('-') || !(nextToken in node.subcommands)) + return lastMatch + } + } + + 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 + } + return prev[n] ?? 0 +} + +export function findSuggestions(tree: CommandTree, argv: string[]): string[] { + const results: string[] = [] + + function collectAll(node: CommandNode, path: string[]): void { + if (node.command) + results.push(buildPath(path)) + for (const [key, child] of Object.entries(node.subcommands)) + collectAll(child, [...path, key]) + } + + function traverse(nodes: Record<string, CommandNode>, tokens: string[], path: string[]): void { + const token = tokens[0] + if (token === undefined || token.startsWith('-')) + return + + const rest = tokens.slice(1) + const nextToken = rest.at(0) + for (const [key, node] of Object.entries(nodes)) { + if (editDistance(token, key) <= 1) { + const newPath = [...path, key] + if (nextToken === undefined || nextToken.startsWith('-') || Object.keys(node.subcommands).length === 0) { + collectAll(node, newPath) + } + else { + traverse(node.subcommands, rest, newPath) + } + } + } + } + + traverse(tree, argv, []) + return results +} diff --git a/cli/src/framework/run.test.ts b/cli/src/framework/run.test.ts new file mode 100644 index 0000000000..498b45d826 --- /dev/null +++ b/cli/src/framework/run.test.ts @@ -0,0 +1,353 @@ +import type { CommandConstructor } from './command.js' +import type { CommandTree } from './registry.js' +import { describe, expect, it } from 'vitest' +import { BaseError, newError } from '../errors/base.js' +import { ErrorCode, ExitCode } from '../errors/codes.js' +import { Command } from './command.js' +import { run, sniffOutputFormat } from './run.js' + +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 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 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 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('falls through to generic Error branch 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('oops\n') + expect(result.exit).toBe(1) + }) + + it('handles non-Error throw via String() coercion', async () => { + class Throwing extends Command { + async run(_argv: string[]) { + // eslint-disable-next-line no-throw-literal + throw 'plain string' + } + } + const result = await captureRun(makeTree(Throwing), ['cmd']) + expect(result.stderr).toBe('plain string\n') + expect(result.exit).toBe(1) + }) + + 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('') + }) +}) diff --git a/cli/src/framework/run.ts b/cli/src/framework/run.ts new file mode 100644 index 0000000000..a8c56a604b --- /dev/null +++ b/cli/src/framework/run.ts @@ -0,0 +1,118 @@ +import type { CommandTree } from './registry.js' +import { BaseError } from '../errors/base.js' +import { formatErrorForCli } from '../errors/format.js' +import { formatHelp } from './help.js' +import { stringifyOutput } from './output.js' +import { findSuggestions, resolveCommand } from './registry.js' + +export async function run(tree: CommandTree, argv: string[]): Promise<void> { + if (argv.length === 0 || argv[0] === 'help' || argv.includes('--help') || argv.includes('-h')) { + const helpArgv = argv.filter(a => a !== '--help' && a !== '-h' && a !== 'help') + + if (helpArgv.length > 0) { + const resolved = resolveCommand(tree, helpArgv) + + if (resolved) { + process.stdout.write(`${formatHelp(resolved.command, resolved.path.join(' '))}\n`) + + return + } + } + + printTopLevelHelp(tree) + + 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 output = await cmd.run(argv.slice(resolved.path.length)) + if (output !== undefined) + process.stdout.write(stringifyOutput(output)) + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EPIPE') + process.exit(0) + if (err instanceof BaseError) { + const format = sniffOutputFormat(argv) + process.stderr.write(`${formatErrorForCli(err, { format, isErrTTY: process.stderr.isTTY })}\n`) + process.exit(err.exit()) + return + } + if (err instanceof Error) { + process.stderr.write(`${err.message}\n`) + process.exit(1) + return + } + process.stderr.write(`${String(err)}\n`) + process.exit(1) + } +} + +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 '' +} + +function printTopLevelHelp(tree: CommandTree): void { + process.stdout.write('difyctl — Dify command-line interface\n\n') + process.stdout.write('COMMANDS\n') + + for (const [topic, node] of Object.entries(tree)) { + if (node.command?.hidden === true) + continue + + if (node.command) { + const desc = node.command.description ?? '' + process.stdout.write(` ${topic} ${desc}\n`) + } + else { + process.stdout.write(` ${topic}\n`) + } + + for (const [verb, sub] of Object.entries(node.subcommands)) { + if (sub.command?.hidden === true) + continue + const desc = sub.command?.description ?? '' + process.stdout.write(` ${verb} ${desc}\n`) + } + } + + process.stdout.write('\n') +} diff --git a/cli/src/framework/types.ts b/cli/src/framework/types.ts new file mode 100644 index 0000000000..1db95e9d16 --- /dev/null +++ b/cli/src/framework/types.ts @@ -0,0 +1,42 @@ +import type { CommandOutput } from './output.js' + +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 helpGroup?: string + readonly options?: readonly string[] + 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/http/client.test.ts b/cli/src/http/client.test.ts new file mode 100644 index 0000000000..580db2bf6d --- /dev/null +++ b/cli/src/http/client.test.ts @@ -0,0 +1,270 @@ +import type { DifyMock } from '../../test/fixtures/dify-mock/server.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { isBaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { createClient } from './client.js' + +describe('http client', () => { + let mock: DifyMock + + beforeEach(async () => { + mock = await startMock() + }) + + afterEach(async () => { + await mock.stop() + }) + + it('GET /workspaces returns parsed JSON when bearer is valid', async () => { + const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) + const body = await client.get('workspaces').json<{ workspaces: unknown[] }>() + expect(body.workspaces).toHaveLength(2) + }) + + it('omits Authorization header when bearer is undefined', async () => { + let captured: string | null = null + const client = createClient({ + host: mock.url, + logger: () => undefined, + bearer: undefined, + }) + try { + await client.get('workspaces', { + hooks: { + beforeRequest: [ + ({ request }) => { captured = request.headers.get('authorization') }, + ], + }, + }).json() + } + 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 = createClient({ host: mock.url, bearer: 'dfoa_test' }) + await client.get('workspaces', { + hooks: { + beforeRequest: [ + ({ request }) => { captured = request.headers.get('authorization') }, + ], + }, + }).json() + expect(captured).toBe('Bearer dfoa_test') + }) + + it('sets a User-Agent header in the difyctl format', async () => { + let captured: string | null = null + const client = createClient({ + host: mock.url, + bearer: 'dfoa_test', + userAgent: 'difyctl/0.0.0-test (test; arm64; dev)', + }) + await client.get('workspaces', { + hooks: { + beforeRequest: [ + ({ request }) => { captured = request.headers.get('user-agent') }, + ], + }, + }).json() + expect(captured).toBe('difyctl/0.0.0-test (test; arm64; dev)') + }) + + it('maps 401 to BaseError(auth_expired)', async () => { + mock.setScenario('auth-expired') + const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) + let caught: unknown + try { + await client.get('workspaces').json() + } + catch (err) { caught = err } + expect(isBaseError(caught)).toBe(true) + if (isBaseError(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 = createClient({ + host: mock.url, + bearer: 'dfoa_test', + retryAttempts: 1, + timeoutMs: 5_000, + }) + let caught: unknown + try { + await client.get('workspaces').json() + } + catch (err) { caught = err } + expect(isBaseError(caught)).toBe(true) + if (isBaseError(caught)) { + expect(caught.code).toBe(ErrorCode.Server5xx) + expect(caught.httpStatus).toBe(503) + } + }) + + it('maps DNS failure to BaseError(network_dns)', async () => { + const client = createClient({ + host: 'http://nonexistent-host-12345.invalid', + bearer: 'dfoa_test', + retryAttempts: 0, + timeoutMs: 3_000, + }) + let caught: unknown + try { + await client.get('workspaces').json() + } + 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 = createClient({ + host: mock.url, + bearer: 'dfoa_test', + logger: e => events.push({ phase: e.phase, status: e.status }), + }) + await client.get('workspaces').json() + 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 (no trailing slash collapses correctly)', async () => { + const client = createClient({ host: `${mock.url}/`, bearer: 'dfoa_test' }) + const body = await client.get('workspaces').json<{ workspaces: unknown[] }>() + expect(body.workspaces).toHaveLength(2) + }) + + it('preserves error envelope hint when server returns one', async () => { + const client = createClient({ host: mock.url, bearer: 'dfoa_test' }) + let caught: unknown + try { + await client.get('apps/nope/describe').json() + } + catch (err) { caught = err } + expect(isBaseError(caught)).toBe(true) + if (isBaseError(caught)) + expect(caught.code).toBe(ErrorCode.Server4xxOther) + }) + + it('handles 429 via retry status code list (eventual server-error class)', async () => { + mock.setScenario('rate-limited') + const client = createClient({ + host: mock.url, + bearer: 'dfoa_test', + retryAttempts: 0, + timeoutMs: 5_000, + }) + let caught: unknown + try { + await client.get('workspaces').json() + } + catch (err) { caught = err } + expect(isBaseError(caught)).toBe(true) + if (isBaseError(caught)) + expect(caught.httpStatus).toBe(429) + }) + + it('does not retry POST on 503', async () => { + mock.setScenario('server-5xx') + let attempts = 0 + const client = createClient({ + host: 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' } }).json()) + .rejects + .toBeDefined() + expect(attempts).toBe(1) + }) + + it('does not retry POST on network error (method allowlist gates retry)', async () => { + let attempts = 0 + const client = createClient({ + host: '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' } }).json(), + ).rejects.toBeDefined() + expect(attempts).toBe(1) + }) + + it('retries GET on network error up to retryAttempts', async () => { + let attempts = 0 + const client = createClient({ + host: 'http://nonexistent-host-12345.invalid', + bearer: 'dfoa_test', + retryAttempts: 2, + timeoutMs: 3_000, + logger: (e) => { + if (e.phase === 'request' || e.phase === 'retry') + attempts++ + }, + }) + await expect(client.get('workspaces').json()).rejects.toBeDefined() + expect(attempts).toBe(3) + }, 30_000) + + it('does not retry PATCH on network error (method allowlist gates retry)', async () => { + let attempts = 0 + const client = createClient({ + host: '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: {} }).json(), + ).rejects.toBeDefined() + expect(attempts).toBe(1) + }) +}) + +describe('classifyResponse internals', () => { + it('strips Bearer from logged URLs (sanity check via vi.fn logger)', async () => { + const mock = await startMock() + try { + const logger = vi.fn() + const client = createClient({ + host: mock.url, + bearer: 'dfoa_should_not_log', + logger, + }) + await client.get('workspaces').json() + 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() + } + }) +}) diff --git a/cli/src/http/client.ts b/cli/src/http/client.ts new file mode 100644 index 0000000000..447b9d4649 --- /dev/null +++ b/cli/src/http/client.ts @@ -0,0 +1,63 @@ +import type { AfterResponseHook, BeforeErrorHook, KyInstance } from 'ky' +import type { HttpFactoryOptions, HttpLogger } from './types.js' +import ky from 'ky' +import { BaseError } from '../errors/base.js' +import { userAgent as defaultUserAgent } from '../version/info.js' +import { classifyResponse, classifyTransportError } from './error-mapper.js' +import { applyBearer } from './middleware/auth.js' +import { logBeforeRequest, logBeforeRetry } from './middleware/request-logger.js' +import { applyUserAgent } from './middleware/user-agent.js' +import { redactBearer } from './sanitize.js' + +export const DEFAULT_TIMEOUT_MS = 30_000 +export const DEFAULT_RETRY_ATTEMPTS = 3 + +function trimSlash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s +} + +function logAndClassify(logger: HttpLogger | undefined): AfterResponseHook { + return async ({ request, response, options }) => { + if (logger !== undefined) { + logger({ + phase: 'response', + method: request.method, + url: redactBearer(request.url), + status: response.status, + }) + } + if (!response.ok && options.context?.skipClassify !== true) + throw await classifyResponse(request, response) + return response + } +} + +const mapTransportError: BeforeErrorHook = ({ error }) => { + if (error instanceof BaseError) + return error + return classifyTransportError(error) +} + +export function createClient(opts: HttpFactoryOptions): KyInstance { + const ua = opts.userAgent ?? defaultUserAgent() + return ky.create({ + prefix: `${trimSlash(opts.host)}/openapi/v1/`, + timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + retry: { + limit: opts.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS, + methods: ['get', 'put', 'delete'], + statusCodes: [408, 413, 429, 500, 502, 503, 504], + }, + throwHttpErrors: false, + hooks: { + beforeRequest: [ + applyUserAgent(ua), + applyBearer(opts.bearer), + logBeforeRequest(opts.logger), + ], + afterResponse: [logAndClassify(opts.logger)], + beforeRetry: [logBeforeRetry(opts.logger)], + beforeError: [mapTransportError], + }, + }) +} diff --git a/cli/src/http/error-mapper.ts b/cli/src/http/error-mapper.ts new file mode 100644 index 0000000000..4d687ccbb5 --- /dev/null +++ b/cli/src/http/error-mapper.ts @@ -0,0 +1,85 @@ +import type { BaseError } from '../errors/base.js' +import { newError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' +import { redactBearer } from './sanitize.js' + +type WireFields = { + code?: string + message?: string + hint?: string +} + +type WireEnvelope = WireFields & { + error?: WireFields +} + +async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> { + let raw = '' + try { + raw = await response.text() + } + catch { + return { raw: '' } + } + if (raw === '') + return { raw } + try { + return { raw, parsed: JSON.parse(raw) as WireEnvelope } + } + catch { + return { raw } + } +} + +export async function classifyResponse(request: Request, response: Response): Promise<BaseError> { + const { parsed } = await readBody(response.clone()) + const wire: WireFields = parsed?.error ?? parsed ?? {} + const status = response.status + const url = redactBearer(response.url || request.url) + const method = request.method + + if (status === 401) { + return newError( + ErrorCode.AuthExpired, + wire.message ?? 'session expired or revoked', + ) + .withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again') + .withHttpStatus(status) + .withRequest(method, url) + } + + if (status >= 500) { + return newError( + ErrorCode.Server5xx, + wire.message ?? `server error (HTTP ${status})`, + ) + .withHttpStatus(status) + .withRequest(method, url) + } + + const err = newError( + ErrorCode.Server4xxOther, + wire.message ?? `request failed (HTTP ${status})`, + ) + .withHttpStatus(status) + .withRequest(method, url) + return wire.hint !== undefined ? err.withHint(wire.hint) : err +} + +export function classifyTransportError(err: unknown): BaseError { + const message = err instanceof Error ? err.message : String(err) + const sanitized = redactBearer(message) + + if (err instanceof Error && err.name === 'TimeoutError') + return newError(ErrorCode.NetworkTimeout, 'request timed out').wrap(err) + if (err instanceof Error && err.name === 'AbortError') + return newError(ErrorCode.NetworkTimeout, 'request aborted').wrap(err) + if (sanitized.toLowerCase().includes('econnrefused')) + return newError(ErrorCode.NetworkDns, 'connection refused').wrap(err) + if (sanitized.toLowerCase().includes('enotfound')) + return newError(ErrorCode.NetworkDns, 'host lookup failed').wrap(err) + if (sanitized.toLowerCase().includes('etimedout')) + return newError(ErrorCode.NetworkTimeout, 'connection timed out').wrap(err) + + return newError(ErrorCode.Unknown, sanitized).wrap(err) +} diff --git a/cli/src/http/middleware/auth.ts b/cli/src/http/middleware/auth.ts new file mode 100644 index 0000000000..c9abace468 --- /dev/null +++ b/cli/src/http/middleware/auth.ts @@ -0,0 +1,10 @@ +import type { BeforeRequestHook } from 'ky' + +export function applyBearer(token: string | undefined): BeforeRequestHook { + return ({ request }) => { + if (token === undefined || token === '') + return + if (!request.headers.has('authorization')) + request.headers.set('authorization', `Bearer ${token}`) + } +} diff --git a/cli/src/http/middleware/request-logger.ts b/cli/src/http/middleware/request-logger.ts new file mode 100644 index 0000000000..8fd31d3d81 --- /dev/null +++ b/cli/src/http/middleware/request-logger.ts @@ -0,0 +1,30 @@ +import type { BeforeRequestHook, BeforeRetryHook } from 'ky' +import type { HttpLogger } from '../types.js' +import { redactBearer } from '../sanitize.js' + +const START_TIME = Symbol('difyctl-http-start') + +type Timed = { [START_TIME]?: number } + +export function logBeforeRequest(logger: HttpLogger | undefined): BeforeRequestHook { + if (logger === undefined) + return () => undefined + return ({ request }) => { + const safeUrl = redactBearer(request.url) + ;(request as unknown as Timed)[START_TIME] = performance.now() + logger({ phase: 'request', method: request.method, url: safeUrl }) + } +} + +export function logBeforeRetry(logger: HttpLogger | undefined): BeforeRetryHook { + if (logger === undefined) + return () => undefined + return ({ request, retryCount }) => { + logger({ + phase: 'retry', + method: request.method, + url: redactBearer(request.url), + attempt: retryCount, + }) + } +} diff --git a/cli/src/http/middleware/user-agent.ts b/cli/src/http/middleware/user-agent.ts new file mode 100644 index 0000000000..a6ab540924 --- /dev/null +++ b/cli/src/http/middleware/user-agent.ts @@ -0,0 +1,8 @@ +import type { BeforeRequestHook } from 'ky' + +export function applyUserAgent(value: string): BeforeRequestHook { + return ({ request }) => { + if (!request.headers.has('user-agent')) + request.headers.set('user-agent', value) + } +} diff --git a/cli/src/http/sanitize.ts b/cli/src/http/sanitize.ts new file mode 100644 index 0000000000..791cc7627b --- /dev/null +++ b/cli/src/http/sanitize.ts @@ -0,0 +1,5 @@ +const BEARER_PATTERN = /Bearer\s+([\w.~+/=-]+)/g + +export function redactBearer(input: string): string { + return input.replace(BEARER_PATTERN, 'Bearer [redacted]') +} diff --git a/cli/src/http/sse-dify.test.ts b/cli/src/http/sse-dify.test.ts new file mode 100644 index 0000000000..b0823ff4bb --- /dev/null +++ b/cli/src/http/sse-dify.test.ts @@ -0,0 +1,95 @@ +import type { SseEvent } from './sse.js' +import { describe, expect, it } from 'vitest' +import { eventNameFromDifyData, normalizeDifyStream } from './sse-dify.js' + +const enc = new TextEncoder() + +function bytes(s: string): Uint8Array { + return enc.encode(s) +} + +async function* fromArray(events: SseEvent[]): AsyncGenerator<SseEvent, void, void> { + for (const ev of events) + yield ev +} + +async function collect(iter: AsyncIterable<SseEvent>): Promise<SseEvent[]> { + const out: SseEvent[] = [] + for await (const ev of iter) + out.push(ev) + return out +} + +describe('eventNameFromDifyData', () => { + it('returns empty string for zero-byte data', () => { + expect(eventNameFromDifyData(new Uint8Array())).toBe('') + }) + + it('returns embedded event name for object payload', () => { + expect(eventNameFromDifyData(bytes('{"event":"message","answer":"hi"}'))).toBe('message') + }) + + it('returns empty string for malformed JSON', () => { + expect(eventNameFromDifyData(bytes('not-json'))).toBe('') + }) + + it('returns empty string for non-string event field', () => { + expect(eventNameFromDifyData(bytes('{"event":42}'))).toBe('') + }) + + it('returns empty string for null payload', () => { + expect(eventNameFromDifyData(bytes('null'))).toBe('') + }) + + it('returns empty string for non-object JSON values', () => { + expect(eventNameFromDifyData(bytes('"just a string"'))).toBe('') + expect(eventNameFromDifyData(bytes('123'))).toBe('') + expect(eventNameFromDifyData(bytes('true'))).toBe('') + }) + + it('returns empty string for object missing event key', () => { + expect(eventNameFromDifyData(bytes('{"answer":"hi"}'))).toBe('') + }) +}) + +describe('normalizeDifyStream', () => { + it('promotes JSON event field into ev.name when transport name absent', async () => { + const out = await collect(normalizeDifyStream(fromArray([ + { name: '', data: bytes('{"event":"workflow_started","id":"wf-1"}') }, + { name: '', data: bytes('{"event":"workflow_finished","status":"succeeded"}') }, + ]))) + expect(out.map(e => e.name)).toEqual(['workflow_started', 'workflow_finished']) + }) + + it('preserves transport-level event name over JSON event field', async () => { + const out = await collect(normalizeDifyStream(fromArray([ + { name: 'ping', data: bytes('') }, + { name: 'foo', data: bytes('{"event":"bar"}') }, + ]))) + expect(out.map(e => e.name)).toEqual(['ping', 'foo']) + }) + + it('forwards unchanged when ev.name absent and data has no JSON event field', async () => { + const ev: SseEvent = { name: '', data: bytes('{"answer":"hi"}') } + const out = await collect(normalizeDifyStream(fromArray([ev]))) + expect(out).toHaveLength(1) + expect(out[0].name).toBe('') + expect(out[0].data).toBe(ev.data) + }) + + it('forwards unchanged when data is malformed JSON', async () => { + const out = await collect(normalizeDifyStream(fromArray([ + { name: '', data: bytes('not-json') }, + ]))) + expect(out).toHaveLength(1) + expect(out[0].name).toBe('') + }) + + it('forwards empty-data events with empty name', async () => { + const out = await collect(normalizeDifyStream(fromArray([ + { name: '', data: bytes('') }, + ]))) + expect(out).toHaveLength(1) + expect(out[0].name).toBe('') + }) +}) diff --git a/cli/src/http/sse-dify.ts b/cli/src/http/sse-dify.ts new file mode 100644 index 0000000000..a26499fa1a --- /dev/null +++ b/cli/src/http/sse-dify.ts @@ -0,0 +1,36 @@ +import type { SseEvent } from './sse.js' + +const dec = new TextDecoder() + +export function eventNameFromDifyData(data: Uint8Array): string { + if (data.byteLength === 0) + return '' + try { + const obj = JSON.parse(dec.decode(data)) as unknown + if (obj === null || typeof obj !== 'object') + return '' + const evt = (obj as { event?: unknown }).event + return typeof evt === 'string' ? evt : '' + } + catch { + return '' + } +} + +// Dify always sends JSON-encoded SSE data. Most endpoints embed the event +// name in the JSON `event` field rather than emitting a transport-level +// `event:` line. This adapter promotes the embedded name into `ev.name` +// so consumers can dispatch uniformly. Transport-level `event:` lines win +// when both are present, preserving compatibility with `event: ping`. +export async function* normalizeDifyStream( + iter: AsyncIterable<SseEvent>, +): AsyncGenerator<SseEvent, void, void> { + for await (const ev of iter) { + if (ev.name !== '') { + yield ev + continue + } + const name = eventNameFromDifyData(ev.data) + yield name === '' ? ev : { ...ev, name } + } +} diff --git a/cli/src/http/sse.test.ts b/cli/src/http/sse.test.ts new file mode 100644 index 0000000000..03d5879e13 --- /dev/null +++ b/cli/src/http/sse.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { parseSSE } from './sse.js' + +function streamOf(...chunks: string[]): ReadableStream<Uint8Array> { + const enc = new TextEncoder() + return new ReadableStream({ + start(c) { + for (const ch of chunks) c.enqueue(enc.encode(ch)) + c.close() + }, + }) +} + +async function collect(s: ReadableStream<Uint8Array>): Promise<{ name: string, data: string, id?: string }[]> { + const out: { name: string, data: string, id?: string }[] = [] + const dec = new TextDecoder() + for await (const ev of parseSSE(s)) + out.push({ name: ev.name, data: dec.decode(ev.data), id: ev.id }) + return out +} + +describe('parseSSE', () => { + it('emits one event per blank-line-terminated record', async () => { + const s = streamOf('event: message\ndata: hello\n\nevent: ping\ndata: \n\n') + const got = await collect(s) + expect(got).toEqual([ + { name: 'message', data: 'hello', id: undefined }, + { name: 'ping', data: '', id: undefined }, + ]) + }) + + it('joins multi-line data with newlines', async () => { + const s = streamOf('event: message\ndata: line1\ndata: line2\n\n') + const got = await collect(s) + expect(got[0]?.data).toBe('line1\nline2') + }) + + it('propagates id field', async () => { + const s = streamOf('id: 42\nevent: m\ndata: x\n\n') + expect((await collect(s))[0]?.id).toBe('42') + }) + + it('skips comment lines', async () => { + const s = streamOf(': comment\nevent: m\ndata: x\n\n') + expect(await collect(s)).toEqual([{ name: 'm', data: 'x', id: undefined }]) + }) + + it('survives chunk boundaries inside a field', async () => { + const s = streamOf('event: mes', 'sage\nda', 'ta: hel', 'lo\n\n') + expect((await collect(s))[0]).toEqual({ name: 'message', data: 'hello', id: undefined }) + }) + + it('handles multi-byte utf-8 split across chunks', async () => { + const enc = new TextEncoder().encode('event: m\ndata: 😀\n\n') + const a = enc.slice(0, 14) + const b = enc.slice(14) + const s = new ReadableStream<Uint8Array>({ + start(c) { + c.enqueue(a) + c.enqueue(b) + c.close() + }, + }) + expect((await collect(s))[0]?.data).toBe('😀') + }) + + it('aborts when signal fires', async () => { + const ctrl = new AbortController() + const slow = new ReadableStream<Uint8Array>({ + pull(c) { + c.enqueue(new TextEncoder().encode('event: m\ndata: x\n\n')) + }, + }) + let seen = 0 + let caught: unknown + try { + for await (const _ of parseSSE(slow, ctrl.signal)) { + seen++ + if (seen === 1) + ctrl.abort() + } + } + catch (e) { + caught = e + } + expect(seen).toBeGreaterThanOrEqual(1) + expect(seen).toBeLessThan(50) + expect((caught as Error).name).toBe('AbortError') + }) +}) diff --git a/cli/src/http/sse.ts b/cli/src/http/sse.ts new file mode 100644 index 0000000000..5af7692e42 --- /dev/null +++ b/cli/src/http/sse.ts @@ -0,0 +1,107 @@ +import { createParser } from 'eventsource-parser' + +export type SseEvent = { + name: string + data: Uint8Array + id?: string +} + +export async function* parseSSE( + body: ReadableStream<Uint8Array>, + signal?: AbortSignal, +): AsyncGenerator<SseEvent, void, void> { + const queue: SseEvent[] = [] + let resolveNext: (() => void) | undefined + let pendingWake = false + let done = false + + const wake = (): void => { + pendingWake = true + if (resolveNext !== undefined) { + const r = resolveNext + resolveNext = undefined + r() + } + } + + const enc = new TextEncoder() + const parser = createParser({ + onEvent(ev) { + queue.push({ + name: ev.event ?? '', + data: enc.encode(ev.data), + id: ev.id, + }) + wake() + }, + }) + + const reader = body.getReader() + const dec = new TextDecoder('utf-8') + + const onAbort = (): void => { + reader.cancel().catch(() => {}) + } + if (signal !== undefined) { + if (signal.aborted) + onAbort() + else + signal.addEventListener('abort', onAbort, { once: true }) + } + + const pump = (async () => { + try { + while (true) { + if (signal?.aborted) { + const e = new Error('aborted') + e.name = 'AbortError' + throw e + } + const { value, done: rDone } = await reader.read() + if (rDone) + break + parser.feed(dec.decode(value, { stream: true })) + } + } + finally { + done = true + wake() + try { + reader.releaseLock() + } + catch {} + } + })() + + try { + while (true) { + while (queue.length > 0) { + const ev = queue.shift() + if (ev !== undefined) + yield ev + } + if (done) { + await pump + return + } + if (pendingWake) { + pendingWake = false + continue + } + await new Promise<void>((res) => { + resolveNext = res + }) + pendingWake = false + } + } + finally { + if (signal !== undefined) + signal.removeEventListener('abort', onAbort) + if (!done) { + try { + await reader.cancel() + } + catch {} + } + } +} diff --git a/cli/src/http/types.ts b/cli/src/http/types.ts new file mode 100644 index 0000000000..c83749acb0 --- /dev/null +++ b/cli/src/http/types.ts @@ -0,0 +1,21 @@ +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 +} + +export type HttpLogger = (event: HttpLogEvent) => void + +export type HttpFactoryOptions = { + readonly host: string + readonly bearer?: string + readonly timeoutMs?: number + readonly retryAttempts?: number + readonly userAgent?: string + readonly logger?: HttpLogger +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000000..9fbe70478e --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,2 @@ +export { longVersion, shortVersion, userAgent, versionInfo } from './version/info.js' +export type { Channel, VersionInfo } from './version/info.js' diff --git a/cli/src/io/color.ts b/cli/src/io/color.ts new file mode 100644 index 0000000000..4cafe9bb9d --- /dev/null +++ b/cli/src/io/color.ts @@ -0,0 +1,50 @@ +import pc from 'picocolors' + +export type ColorScheme = { + bold: (s: string) => string + dim: (s: string) => string + cyan: (s: string) => string + green: (s: string) => string + yellow: (s: string) => string + magenta: (s: string) => string + successIcon: () => string + warningIcon: () => string + failureIcon: () => string +} + +const identity = (s: string): string => s + +export function colorScheme(enabled: boolean): ColorScheme { + if (!enabled) { + return { + bold: identity, + dim: identity, + cyan: identity, + green: identity, + yellow: identity, + magenta: identity, + successIcon: () => '✓', + warningIcon: () => '!', + failureIcon: () => '✗', + } + } + return { + bold: s => pc.bold(s), + dim: s => pc.dim(s), + cyan: s => pc.cyan(s), + green: s => pc.green(s), + yellow: s => pc.yellow(s), + magenta: s => pc.magenta(s), + successIcon: () => pc.green('✓'), + warningIcon: () => pc.yellow('!'), + failureIcon: () => pc.red('✗'), + } +} + +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 +} diff --git a/cli/src/io/spinner.ts b/cli/src/io/spinner.ts new file mode 100644 index 0000000000..04e7c4d9fe --- /dev/null +++ b/cli/src/io/spinner.ts @@ -0,0 +1,105 @@ +import type { IOStreams } from './streams.js' +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/io/streams.ts b/cli/src/io/streams.ts new file mode 100644 index 0000000000..a51f630f62 --- /dev/null +++ b/cli/src/io/streams.ts @@ -0,0 +1,61 @@ +import { Buffer } from 'node:buffer' +import { PassThrough, Readable, Writable } from 'node:stream' + +export type IOStreams = { + out: NodeJS.WritableStream + err: NodeJS.WritableStream + in: NodeJS.ReadableStream + isOutTTY: boolean + isErrTTY: boolean + outputFormat: string +} + +export function nullStreams(): IOStreams { + return bufferStreams() +} + +export function realStreams(outputFormat = ''): IOStreams { + return { + out: process.stdout, + err: process.stderr, + in: process.stdin, + isOutTTY: Boolean(process.stdout.isTTY), + isErrTTY: Boolean(process.stderr.isTTY), + outputFormat, + } +} + +export type BufferStreams = IOStreams & { + outBuf: () => string + errBuf: () => string +} + +export function bufferStreams(stdin = ''): BufferStreams { + const outChunks: Buffer[] = [] + const errChunks: Buffer[] = [] + const out = new Writable({ + write(chunk, _enc, cb) { + outChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + cb() + }, + }) as unknown as NodeJS.WritableStream + const err = new Writable({ + write(chunk, _enc, cb) { + errChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) + cb() + }, + }) as unknown as NodeJS.WritableStream + const inStream: NodeJS.ReadableStream = stdin === '' + ? new PassThrough() + : Readable.from([stdin]) + return { + out, + err, + in: inStream, + isOutTTY: false, + isErrTTY: false, + outputFormat: '', + outBuf: () => Buffer.concat(outChunks).toString('utf8'), + errBuf: () => Buffer.concat(errChunks).toString('utf8'), + } +} diff --git a/cli/src/io/think-filter.test.ts b/cli/src/io/think-filter.test.ts new file mode 100644 index 0000000000..c883a9dd1a --- /dev/null +++ b/cli/src/io/think-filter.test.ts @@ -0,0 +1,186 @@ +import { Buffer } from 'node:buffer' +import { PassThrough } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { extractThinkBlocks, stripThinkBlocks, ThinkChunkFilter } from './think-filter.js' + +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>') + }) +}) + +// --- 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/io/think-filter.ts b/cli/src/io/think-filter.ts new file mode 100644 index 0000000000..401ea728f2 --- /dev/null +++ b/cli/src/io/think-filter.ts @@ -0,0 +1,94 @@ +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') } +} + +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/limit/limit.test.ts b/cli/src/limit/limit.test.ts new file mode 100644 index 0000000000..b8f377be25 --- /dev/null +++ b/cli/src/limit/limit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { isBaseError } from '../errors/base.js' +import { ExitCode } from '../errors/codes.js' +import { LIMIT_DEFAULT, LIMIT_MAX, LIMIT_MIN, parseLimit } from './limit.js' + +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 new file mode 100644 index 0000000000..91bdd204bf --- /dev/null +++ b/cli/src/limit/limit.ts @@ -0,0 +1,25 @@ +import { newError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' + +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 +} diff --git a/cli/src/printers/format-json-yaml.test.ts b/cli/src/printers/format-json-yaml.test.ts new file mode 100644 index 0000000000..d37fd39652 --- /dev/null +++ b/cli/src/printers/format-json-yaml.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { JsonYamlPrintFlags } from './format-json-yaml.js' +import { isNoCompatiblePrinter } from './printer.js' + +describe('JsonYamlPrintFlags.allowedFormats', () => { + it('returns json + yaml', () => { + expect(new JsonYamlPrintFlags().allowedFormats()).toEqual(['json', 'yaml']) + }) +}) + +describe('JsonYamlPrintFlags.toPrinter', () => { + it('throws NoCompatiblePrinterError for unsupported formats', () => { + const pf = new JsonYamlPrintFlags() + for (const f of ['', 'text', 'wide', 'name', 'xml']) { + let caught: unknown + try { + pf.toPrinter(f) + } + catch (e) { + caught = e + } + expect(isNoCompatiblePrinter(caught)).toBe(true) + } + }) + + it('returns a json printer that encodes raw payload with 2-space indent', () => { + const p = new JsonYamlPrintFlags().toPrinter('json') + const out = p.print({ raw: () => ({ answer: 'hi' }) }) + expect(out).toContain('"answer"') + expect(out).toContain('"hi"') + expect(out).toContain(' "answer"') + expect(out.endsWith('\n')).toBe(true) + }) + + it('json printer round-trips a plain object with no Raw()', () => { + const p = new JsonYamlPrintFlags().toPrinter('json') + const out = p.print({ k: 'v', n: 1 }) + expect(JSON.parse(out)).toEqual({ k: 'v', n: 1 }) + }) + + it('json printer is lossless for nested arrays', () => { + const data = { items: [{ id: 'a' }, { id: 'b' }] } + const out = new JsonYamlPrintFlags().toPrinter('json').print(data) + expect(JSON.parse(out)).toEqual(data) + }) + + it('returns a yaml printer that emits scalar pairs', () => { + const p = new JsonYamlPrintFlags().toPrinter('yaml') + const out = p.print({ raw: () => ({ answer: 'hi' }) }) + expect(out).toMatch(/answer:\s*['"]?hi['"]?\n?/) + }) + + it('yaml printer round-trips structured data', async () => { + const yaml = await import('js-yaml') + const data = { items: [{ id: 'a', mode: 'chat' }, { id: 'b', mode: 'workflow' }] } + const out = new JsonYamlPrintFlags().toPrinter('yaml').print(data) + expect(yaml.load(out)).toEqual(data) + }) +}) diff --git a/cli/src/printers/format-json-yaml.ts b/cli/src/printers/format-json-yaml.ts new file mode 100644 index 0000000000..b6d895b753 --- /dev/null +++ b/cli/src/printers/format-json-yaml.ts @@ -0,0 +1,31 @@ +import type { Printer, PrintFlags } from './printer.js' +import yaml from 'js-yaml' +import { NoCompatiblePrinterError, payload } from './printer.js' + +const ALLOWED = ['json', 'yaml'] as const + +const jsonPrinter: Printer = { + print(obj) { + return `${JSON.stringify(payload(obj), null, 2)}\n` + }, +} + +const yamlPrinter: Printer = { + print(obj) { + return yaml.dump(payload(obj), { indent: 2, lineWidth: -1 }) + }, +} + +export class JsonYamlPrintFlags implements PrintFlags { + allowedFormats(): readonly string[] { + return ALLOWED + } + + toPrinter(format: string): Printer { + switch (format) { + case 'json': return jsonPrinter + case 'yaml': return yamlPrinter + default: throw new NoCompatiblePrinterError(format, ALLOWED) + } + } +} diff --git a/cli/src/printers/format-name.test.ts b/cli/src/printers/format-name.test.ts new file mode 100644 index 0000000000..c4a6ff0df0 --- /dev/null +++ b/cli/src/printers/format-name.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { NamePrintFlags } from './format-name.js' +import { isNoCompatiblePrinter } from './printer.js' + +const fakeMode = (m: string) => ({ mode: () => m }) + +describe('NamePrintFlags.allowedFormats', () => { + it('returns ["name"]', () => { + expect(new NamePrintFlags().allowedFormats()).toEqual(['name']) + }) +}) + +describe('NamePrintFlags.toPrinter', () => { + it('throws NoCompatiblePrinterError for non-name formats', () => { + const pf = new NamePrintFlags() + let caught: unknown + try { + pf.toPrinter('json') + } + catch (e) { + caught = e + } + expect(isNoCompatiblePrinter(caught)).toBe(true) + }) + + it('prints id + newline for the registered mode', () => { + const pf = new NamePrintFlags() + pf.register({ id: () => 'abc-123' }, 'thing') + expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc-123\n') + }) + + it('appends operation suffix when set', () => { + const pf = new NamePrintFlags() + pf.operation = 'created' + pf.register({ id: () => 'abc' }, 'thing') + expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc created\n') + }) + + it('throws when payload mode has no registered handler', () => { + const pf = new NamePrintFlags() + pf.register({ id: () => 'abc' }, 'thing') + const printer = pf.toPrinter('name') + expect(() => printer.print(fakeMode('other'))).toThrow(/no handler for mode/) + }) + + it('throws when payload does not implement Moder', () => { + const pf = new NamePrintFlags() + pf.register({ id: () => 'abc' }, 'thing') + const printer = pf.toPrinter('name') + expect(() => printer.print({ no: 'mode' })).toThrow(/does not implement Moder/i) + }) + + it('register accepts multiple keys for the same handler', () => { + const pf = new NamePrintFlags() + pf.register({ id: () => 'shared' }, 'a', 'b') + const printer = pf.toPrinter('name') + expect(printer.print(fakeMode('a'))).toBe('shared\n') + expect(printer.print(fakeMode('b'))).toBe('shared\n') + }) + + it('unwraps RawObject before passing payload to handler', () => { + const pf = new NamePrintFlags() + let received: unknown + pf.register({ + id: (p) => { + received = p + return 'ok' + }, + }, 'thing') + pf.toPrinter('name').print({ + mode: () => 'thing', + raw: () => ({ id: 'unwrapped' }), + }) + expect(received).toEqual({ id: 'unwrapped' }) + }) +}) diff --git a/cli/src/printers/format-name.ts b/cli/src/printers/format-name.ts new file mode 100644 index 0000000000..b6ba448ced --- /dev/null +++ b/cli/src/printers/format-name.ts @@ -0,0 +1,42 @@ +import type { Printer, PrintFlags } from './printer.js' +import { isModer, NoCompatiblePrinterError, payload } from './printer.js' + +const ALLOWED = ['name'] as const + +export type NameHandler = { + id: (raw: unknown) => string +} + +export class NamePrintFlags implements PrintFlags { + operation = '' + private readonly handlers = new Map<string, NameHandler>() + + register(handler: NameHandler, ...keys: string[]): void { + for (const k of keys) this.handlers.set(k, handler) + } + + allowedFormats(): readonly string[] { + return ALLOWED + } + + toPrinter(format: string): Printer { + if (format !== 'name') + throw new NoCompatiblePrinterError(format, ALLOWED) + const handlers = this.handlers + const operation = this.operation + return { + print(obj) { + if (!isModer(obj)) + throw new Error(`name printer: payload does not implement Moder`) + const mode = obj.mode() + const h = handlers.get(mode) + if (h === undefined) { + const known = [...handlers.keys()].sort().join(', ') + throw new Error(`name printer: no handler for mode "${mode}" (registered: ${known})`) + } + const id = h.id(payload(obj)) + return operation === '' ? `${id}\n` : `${id} ${operation}\n` + }, + } + } +} diff --git a/cli/src/printers/format-table.test.ts b/cli/src/printers/format-table.test.ts new file mode 100644 index 0000000000..2652f4b0f8 --- /dev/null +++ b/cli/src/printers/format-table.test.ts @@ -0,0 +1,136 @@ +import type { TableColumn, TableHandler } from './format-table.js' +import { describe, expect, it } from 'vitest' +import { TablePrintFlags } from './format-table.js' +import { isNoCompatiblePrinter } from './printer.js' + +const fakeMode = (m: string) => ({ mode: () => m }) + +const handler: TableHandler = { + columns(): readonly TableColumn[] { + return [ + { name: 'NAME', priority: 0 }, + { name: 'AGE', priority: 0 }, + { name: 'DETAILS', priority: 1 }, + ] + }, + rows() { + return [['alpha', '1d', 'extra']] + }, +} + +describe('TablePrintFlags.allowedFormats', () => { + it('returns ["", "wide"]', () => { + expect(new TablePrintFlags().allowedFormats()).toEqual(['', 'wide']) + }) +}) + +describe('TablePrintFlags default format', () => { + it('hides priority>0 columns and their cells', () => { + const pf = new TablePrintFlags() + pf.register(handler, 'thing') + const out = pf.toPrinter('').print(fakeMode('thing')) + expect(out).toContain('NAME') + expect(out).toContain('AGE') + expect(out).not.toContain('DETAILS') + expect(out).not.toContain('extra') + expect(out).toContain('alpha') + }) + + it('column-aligns cells with two-space padding', () => { + const pf = new TablePrintFlags() + pf.register({ + columns: () => [ + { name: 'NAME', priority: 0 }, + { name: 'AGE', priority: 0 }, + ], + rows: () => [ + ['alpha', '1d'], + ['beta-long', '999d'], + ], + }, 'thing') + const out = pf.toPrinter('').print(fakeMode('thing')) + const lines = out.trimEnd().split('\n') + expect(lines).toHaveLength(3) + expect(lines[0]).toBe('NAME AGE') + expect(lines[1]).toBe('alpha 1d') + expect(lines[2]).toBe('beta-long 999d') + }) +}) + +describe('TablePrintFlags wide format', () => { + it('shows all columns including priority>0', () => { + const pf = new TablePrintFlags() + pf.register(handler, 'thing') + const out = pf.toPrinter('wide').print(fakeMode('thing')) + expect(out).toContain('DETAILS') + expect(out).toContain('extra') + }) +}) + +describe('TablePrintFlags noHeaders', () => { + it('omits header row when noHeaders=true', () => { + const pf = new TablePrintFlags({ noHeaders: true }) + pf.register(handler, 'thing') + const out = pf.toPrinter('').print(fakeMode('thing')) + expect(out).not.toContain('NAME') + expect(out).toContain('alpha') + }) +}) + +describe('TablePrintFlags errors', () => { + it('throws NoCompatiblePrinterError for unsupported formats', () => { + let caught: unknown + try { + new TablePrintFlags().toPrinter('json') + } + catch (e) { + caught = e + } + expect(isNoCompatiblePrinter(caught)).toBe(true) + }) + + it('throws on unregistered mode', () => { + const pf = new TablePrintFlags() + pf.register(handler, 'thing') + const printer = pf.toPrinter('') + expect(() => printer.print(fakeMode('other'))).toThrow(/other/) + }) + + it('throws when payload does not implement Moder', () => { + const pf = new TablePrintFlags() + pf.register(handler, 'thing') + expect(() => pf.toPrinter('').print({})).toThrow(/Moder/i) + }) + + it('handler rows() can return null/undefined cells safely (rendered empty)', () => { + const pf = new TablePrintFlags() + pf.register({ + columns: () => [{ name: 'A', priority: 0 }, { name: 'B', priority: 0 }], + rows: () => [['x', undefined], [null, 'y']], + }, 'thing') + const out = pf.toPrinter('').print(fakeMode('thing')) + const lines = out.trimEnd().split('\n') + expect(lines[0]).toBe('A B') + expect(lines[1]).toBe('x ') + expect(lines[2]).toBe(' y') + }) +}) + +describe('TablePrintFlags raw unwrap', () => { + it('passes unwrapped payload to handler.rows()', () => { + let received: unknown + const pf = new TablePrintFlags() + pf.register({ + columns: () => [{ name: 'X', priority: 0 }], + rows: (p) => { + received = p + return [['ok']] + }, + }, 'thing') + pf.toPrinter('').print({ + mode: () => 'thing', + raw: () => ({ items: [{ id: 'x' }] }), + }) + expect(received).toEqual({ items: [{ id: 'x' }] }) + }) +}) diff --git a/cli/src/printers/format-table.ts b/cli/src/printers/format-table.ts new file mode 100644 index 0000000000..f37b92da1b --- /dev/null +++ b/cli/src/printers/format-table.ts @@ -0,0 +1,108 @@ +import type { Printer, PrintFlags } from './printer.js' +import { isModer, NoCompatiblePrinterError, payload } from './printer.js' + +const ALLOWED = ['', 'wide'] as const +const COLUMN_PADDING = 2 + +export type TableColumn = { + name: string + priority: number +} + +export type TableCell = string | null | undefined + +export type TableRow = readonly TableCell[] + +export type TableHandler = { + columns: () => readonly TableColumn[] + rows: (raw: unknown) => readonly TableRow[] +} + +export type TablePrintFlagsOptions = { + noHeaders?: boolean +} + +export class TablePrintFlags implements PrintFlags { + private readonly handlers = new Map<string, TableHandler>() + private readonly noHeaders: boolean + + constructor(opts: TablePrintFlagsOptions = {}) { + this.noHeaders = opts.noHeaders ?? false + } + + register(handler: TableHandler, ...keys: string[]): void { + for (const k of keys) this.handlers.set(k, handler) + } + + allowedFormats(): readonly string[] { + return ALLOWED + } + + toPrinter(format: string): Printer { + if (format !== '' && format !== 'wide') + throw new NoCompatiblePrinterError(format, ALLOWED) + const wide = format === 'wide' + const handlers = this.handlers + const noHeaders = this.noHeaders + return { + print(obj) { + if (!isModer(obj)) + throw new Error('table printer: payload does not implement Moder') + const mode = obj.mode() + const handler = handlers.get(mode) + if (handler === undefined) { + const known = [...handlers.keys()].sort().join(', ') + throw new Error(`table printer: no handler for mode "${mode}" (registered: ${known})`) + } + const cols = handler.columns() + const keep: number[] = [] + for (let i = 0; i < cols.length; i++) { + const col = cols[i] + if (col !== undefined && (col.priority === 0 || wide)) + keep.push(i) + } + const rows = handler.rows(payload(obj)) + const stringRows: string[][] = rows.map(row => + keep.map((idx) => { + const cell = row[idx] + return cell === null || cell === undefined ? '' : String(cell) + }), + ) + const allRows: string[][] = noHeaders + ? stringRows + : [keep.map(i => cols[i]?.name ?? ''), ...stringRows] + return formatTable(allRows) + }, + } + } +} + +function formatTable(rows: 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] ?? '' + if (cell.length > (widths[i] ?? 0)) + widths[i] = cell.length + } + } + 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) - cell.length + COLUMN_PADDING + cells.push(cell + ' '.repeat(pad)) + } + } + return cells.join('') + }) + return `${lines.join('\n')}\n` +} diff --git a/cli/src/printers/format-text.test.ts b/cli/src/printers/format-text.test.ts new file mode 100644 index 0000000000..4563555b86 --- /dev/null +++ b/cli/src/printers/format-text.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { TextPrintFlags } from './format-text.js' + +describe('TextPrintFlags', () => { + it('routes to handler by mode', () => { + const f = new TextPrintFlags() + f.register({ render: v => `chat:${(v as { x: string }).x}\n` }, 'chat') + f.register({ render: v => `wf:${(v as { y: string }).y}\n` }, 'workflow') + expect(f.toPrinter('').print({ mode: () => 'chat', raw: () => ({ x: '1' }) })).toBe('chat:1\n') + expect(f.toPrinter('text').print({ mode: () => 'workflow', raw: () => ({ y: '2' }) })).toBe('wf:2\n') + }) + + it('rejects unknown formats', () => { + expect(() => new TextPrintFlags().toPrinter('json')).toThrow(/not supported/) + }) + + it('errors on unregistered mode', () => { + const f = new TextPrintFlags() + expect(() => f.toPrinter('').print({ mode: () => 'agent', raw: () => ({}) })).toThrow(/no handler for mode/) + }) +}) diff --git a/cli/src/printers/format-text.ts b/cli/src/printers/format-text.ts new file mode 100644 index 0000000000..61aa1fd12d --- /dev/null +++ b/cli/src/printers/format-text.ts @@ -0,0 +1,39 @@ +import type { Printer, PrintFlags } from './printer.js' +import { isModer, NoCompatiblePrinterError, payload } from './printer.js' + +const ALLOWED = ['', 'text'] as const + +export type TextHandler = { + render: (raw: unknown) => string +} + +export class TextPrintFlags implements PrintFlags { + private readonly handlers = new Map<string, TextHandler>() + + register(handler: TextHandler, ...keys: string[]): void { + for (const k of keys) this.handlers.set(k, handler) + } + + allowedFormats(): readonly string[] { + return ALLOWED + } + + toPrinter(format: string): Printer { + if (format !== '' && format !== 'text') + throw new NoCompatiblePrinterError(format, ALLOWED) + const handlers = this.handlers + return { + print(obj) { + if (!isModer(obj)) + throw new Error('text printer: payload does not implement Moder') + const mode = obj.mode() + const h = handlers.get(mode) + if (h === undefined) { + const known = [...handlers.keys()].sort().join(', ') + throw new Error(`text printer: no handler for mode "${mode}" (registered: ${known})`) + } + return h.render(payload(obj)) + }, + } + } +} diff --git a/cli/src/printers/printer.test.ts b/cli/src/printers/printer.test.ts new file mode 100644 index 0000000000..7a1cf346fb --- /dev/null +++ b/cli/src/printers/printer.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + isModer, + isNoCompatiblePrinter, + isRawObject, + NoCompatiblePrinterError, + payload, +} from './printer.js' + +describe('NoCompatiblePrinterError', () => { + it('mentions format and allowed list when allowed is non-empty', () => { + const err = new NoCompatiblePrinterError('xml', ['json', 'yaml']) + expect(err.message).toContain('xml') + expect(err.message).toContain('json') + expect(err.message).toContain('yaml') + }) + + it('mentions only format when allowed list is empty', () => { + const err = new NoCompatiblePrinterError('xml', []) + expect(err.message).toContain('xml') + expect(err.message).toContain('not supported') + expect(err.message).not.toContain('allowed') + }) + + it('exposes format and allowed publicly for callers that branch on them', () => { + const err = new NoCompatiblePrinterError('xml', ['json']) + expect(err.format).toBe('xml') + expect(err.allowed).toEqual(['json']) + }) + + it('has a stable name for serialization', () => { + const err = new NoCompatiblePrinterError('xml', []) + expect(err.name).toBe('NoCompatiblePrinterError') + }) +}) + +describe('isNoCompatiblePrinter', () => { + it('matches NoCompatiblePrinterError instances', () => { + expect(isNoCompatiblePrinter(new NoCompatiblePrinterError('xml', ['json']))).toBe(true) + }) + + it('does not match plain Error', () => { + expect(isNoCompatiblePrinter(new Error('other'))).toBe(false) + }) + + it('does not match a wrapped error message', () => { + expect(isNoCompatiblePrinter(new Error('wrapped: output format "xml" not supported'))).toBe(false) + }) + + it('does not match null/undefined/primitives', () => { + expect(isNoCompatiblePrinter(null)).toBe(false) + expect(isNoCompatiblePrinter(undefined)).toBe(false) + expect(isNoCompatiblePrinter('string')).toBe(false) + expect(isNoCompatiblePrinter(42)).toBe(false) + }) +}) + +describe('isRawObject', () => { + it('detects objects exposing raw()', () => { + expect(isRawObject({ raw: () => 42 })).toBe(true) + }) + + it('rejects values without raw()', () => { + expect(isRawObject({})).toBe(false) + expect(isRawObject(null)).toBe(false) + expect(isRawObject(undefined)).toBe(false) + expect(isRawObject(42)).toBe(false) + }) + + it('rejects objects where raw is not callable', () => { + expect(isRawObject({ raw: 42 })).toBe(false) + }) +}) + +describe('isModer', () => { + it('detects objects exposing mode()', () => { + expect(isModer({ mode: () => 'chat' })).toBe(true) + }) + + it('rejects values without mode()', () => { + expect(isModer({})).toBe(false) + expect(isModer(null)).toBe(false) + expect(isModer({ mode: 'chat' })).toBe(false) + }) +}) + +describe('payload', () => { + it('unwraps RawObject via raw()', () => { + expect(payload({ raw: () => ({ id: 'a' }) })).toEqual({ id: 'a' }) + }) + + it('returns the value as-is when it is not a RawObject', () => { + const obj = { id: 'a' } + expect(payload(obj)).toBe(obj) + }) + + it('returns primitives untouched', () => { + expect(payload(42)).toBe(42) + expect(payload(null)).toBeNull() + }) +}) diff --git a/cli/src/printers/printer.ts b/cli/src/printers/printer.ts new file mode 100644 index 0000000000..02b47db7fa --- /dev/null +++ b/cli/src/printers/printer.ts @@ -0,0 +1,82 @@ +export type Format = '' | 'wide' | 'json' | 'yaml' | 'name' + +export type Printer = { + print: (obj: unknown) => string +} + +export type RawObject = { + raw: () => unknown +} + +export type Moder = { + mode: () => string +} + +export type PrintFlags = { + allowedFormats: () => readonly string[] + toPrinter: (format: string) => Printer +} + +export class NoCompatiblePrinterError extends Error { + override readonly name = 'NoCompatiblePrinterError' + readonly format: string + readonly allowed: readonly string[] + + constructor(format: string, allowed: readonly string[]) { + super( + allowed.length === 0 + ? `output format ${JSON.stringify(format)} not supported` + : `output format ${JSON.stringify(format)} not supported, allowed: ${allowed.join(', ')}`, + ) + this.format = format + this.allowed = allowed + } +} + +export function isNoCompatiblePrinter(err: unknown): err is NoCompatiblePrinterError { + return err instanceof NoCompatiblePrinterError +} + +export abstract class CompositePrintFlags implements PrintFlags { + protected abstract families(): readonly PrintFlags[] + + allowedFormats(): readonly string[] { + const seen = new Set<string>() + for (const fam of this.families()) { + for (const f of fam.allowedFormats()) { + if (f !== '') + seen.add(f) + } + } + return [...seen].sort() + } + + toPrinter(format: string): Printer { + for (const fam of this.families()) { + try { + return fam.toPrinter(format) + } + catch (err) { + if (!isNoCompatiblePrinter(err)) + throw err + } + } + throw new NoCompatiblePrinterError(format, this.allowedFormats()) + } +} + +export function isRawObject(v: unknown): v is RawObject { + return typeof v === 'object' + && v !== null + && typeof (v as { raw?: unknown }).raw === 'function' +} + +export function isModer(v: unknown): v is Moder { + return typeof v === 'object' + && v !== null + && typeof (v as { mode?: unknown }).mode === 'function' +} + +export function payload(obj: unknown): unknown { + return isRawObject(obj) ? obj.raw() : obj +} diff --git a/cli/src/printers/stream-printer.ts b/cli/src/printers/stream-printer.ts new file mode 100644 index 0000000000..fb19f1fc36 --- /dev/null +++ b/cli/src/printers/stream-printer.ts @@ -0,0 +1,6 @@ +import type { SseEvent } from '../http/sse.js' + +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/printers/width.test.ts b/cli/src/printers/width.test.ts new file mode 100644 index 0000000000..a57e452488 --- /dev/null +++ b/cli/src/printers/width.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TERMINAL_WIDTH_FALLBACK, terminalWidth, truncate } from './width.js' + +describe('truncate', () => { + it('returns the input unchanged when shorter than max', () => { + expect(truncate('hi', 5)).toBe('hi') + }) + + it('returns the input unchanged when exactly at max', () => { + expect(truncate('hello', 5)).toBe('hello') + }) + + it('truncates to max with single ellipsis char when longer', () => { + expect(truncate('hello world', 5)).toBe('hell…') + }) + + it('returns empty for empty input regardless of max', () => { + expect(truncate('', 5)).toBe('') + }) + + it('returns just the ellipsis when max is 1', () => { + expect(truncate('hello', 1)).toBe('…') + }) + + it('returns empty when max is 0', () => { + expect(truncate('hello', 0)).toBe('') + }) + + it('handles negative max gracefully', () => { + expect(truncate('hello', -3)).toBe('') + }) +}) + +describe('terminalWidth', () => { + let originalColumns: number | undefined + + beforeEach(() => { + originalColumns = process.stdout.columns + }) + + afterEach(() => { + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + configurable: true, + writable: true, + }) + }) + + it('returns process.stdout.columns when present', () => { + Object.defineProperty(process.stdout, 'columns', { + value: 120, + configurable: true, + writable: true, + }) + expect(terminalWidth()).toBe(120) + }) + + it('falls back to 80 when columns is undefined', () => { + Object.defineProperty(process.stdout, 'columns', { + value: undefined, + configurable: true, + writable: true, + }) + expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK) + expect(TERMINAL_WIDTH_FALLBACK).toBe(80) + }) + + it('falls back to 80 when columns is 0', () => { + Object.defineProperty(process.stdout, 'columns', { + value: 0, + configurable: true, + writable: true, + }) + expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK) + }) +}) diff --git a/cli/src/printers/width.ts b/cli/src/printers/width.ts new file mode 100644 index 0000000000..e48af55a58 --- /dev/null +++ b/cli/src/printers/width.ts @@ -0,0 +1,17 @@ +export const TERMINAL_WIDTH_FALLBACK = 80 +const ELLIPSIS = '…' + +export function terminalWidth(): number { + const cols = process.stdout.columns + return typeof cols === 'number' && cols > 0 ? cols : TERMINAL_WIDTH_FALLBACK +} + +export function truncate(s: string, max: number): string { + if (s === '' || max <= 0) + return '' + if (s.length <= max) + return s + if (max === 1) + return ELLIPSIS + return s.slice(0, max - 1) + ELLIPSIS +} diff --git a/cli/src/types/app-meta.test.ts b/cli/src/types/app-meta.test.ts new file mode 100644 index 0000000000..d53176f7b8 --- /dev/null +++ b/cli/src/types/app-meta.test.ts @@ -0,0 +1,56 @@ +import type { AppDescribeResponse } from '@dify/contracts/api/openapi/types.gen' +import { describe, expect, it } from 'vitest' +import { covers, FieldInfo, FieldInputSchema, FieldParameters, fromDescribe, mergeMeta } from './app-meta.js' + +function describeResp(): AppDescribeResponse { + return { + info: { + id: 'app-1', + name: 'Greeter', + description: '', + mode: 'chat', + author: 'tester', + tags: [], + 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 new file mode 100644 index 0000000000..2e9ea29525 --- /dev/null +++ b/cli/src/types/app-meta.ts @@ -0,0 +1,63 @@ +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/browser.ts b/cli/src/util/browser.ts new file mode 100644 index 0000000000..3a272cc77a --- /dev/null +++ b/cli/src/util/browser.ts @@ -0,0 +1,51 @@ +import openModule from 'open' + +export const OpenDecision = { + Auto: 'auto-open', + SkipSSH: 'Detected SSH session', + SkipHeadlessLinux: 'Headless Linux (no DISPLAY / WAYLAND_DISPLAY)', + SkipNoTTY: 'Non-interactive TTY', + SkipUserOptOut: '--no-browser requested', +} as const +export type OpenDecision = typeof OpenDecision[keyof typeof OpenDecision] + +export type BrowserEnv = { + getEnv: (key: string) => string | undefined + platform: NodeJS.Platform + isOutTTY: boolean + isErrTTY: boolean +} + +export function realEnv(): BrowserEnv { + return { + getEnv: k => process.env[k], + platform: process.platform, + isOutTTY: Boolean(process.stdout.isTTY), + isErrTTY: Boolean(process.stderr.isTTY), + } +} + +export function decideOpen(env: BrowserEnv, userOptOut: boolean): OpenDecision { + if (userOptOut) + return OpenDecision.SkipUserOptOut + if (truthy(env.getEnv('SSH_CONNECTION')) || truthy(env.getEnv('SSH_TTY'))) + return OpenDecision.SkipSSH + if (env.platform === 'linux' + && !truthy(env.getEnv('DISPLAY')) + && !truthy(env.getEnv('WAYLAND_DISPLAY'))) { + return OpenDecision.SkipHeadlessLinux + } + if (!env.isOutTTY || !env.isErrTTY) + return OpenDecision.SkipNoTTY + return OpenDecision.Auto +} + +export type BrowserOpener = (url: string) => Promise<void> + +export const openUrl: BrowserOpener = async (url) => { + await openModule(url) +} + +function truthy(v: string | undefined): boolean { + return v !== undefined && v !== '' +} diff --git a/cli/src/util/host.ts b/cli/src/util/host.ts new file mode 100644 index 0000000000..dcd6f5f3af --- /dev/null +++ b/cli/src/util/host.ts @@ -0,0 +1,70 @@ +import { BaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' + +export const DEFAULT_HOST = 'https://cloud.dify.ai' + +export type ResolveHostOptions = { + raw: string + insecure: boolean +} + +export function resolveHost(opts: ResolveHostOptions): string { + let raw = opts.raw.trim() + if (raw === '') + raw = DEFAULT_HOST + if (!raw.includes('://')) + raw = `https://${raw}` + let url: URL + try { + url = new URL(raw) + } + catch (err) { + throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `host parse: ${(err as Error).message}` }) + } + url.pathname = url.pathname.replace(/\/+$/, '') + if (url.protocol !== 'https:' && !(opts.insecure && url.protocol === 'http:')) { + throw new BaseError({ + code: ErrorCode.UsageInvalidFlag, + message: 'only https:// hosts are accepted', + hint: 'add --insecure to allow http:// (local-dev only; user_code/device_code travel plaintext)', + }) + } + const out = url.toString() + 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}` +} + +export function bareHost(raw: string): string { + try { + const u = new URL(raw) + return u.host !== '' ? u.host : raw + } + catch { + return raw + } +} + +export function validateVerificationURI(raw: string, insecure: boolean): void { + let url: URL + try { + url = new URL(raw.trim()) + } + catch { + throw new BaseError({ code: ErrorCode.Unknown, message: `server returned invalid verification_uri "${raw}"` }) + } + if (url.protocol !== 'https:' && !(insecure && url.protocol === 'http:')) { + throw new BaseError({ + code: ErrorCode.Unknown, + message: `server returned verification_uri with unsupported scheme "${url.protocol.replace(':', '')}"`, + hint: 'expected https:// (use --insecure to allow http:// on local-dev hosts)', + }) + } + if (url.host === '') + throw new BaseError({ code: ErrorCode.Unknown, message: `server returned verification_uri without host: "${raw}"` }) +} diff --git a/cli/src/version/build-info.d.ts b/cli/src/version/build-info.d.ts new file mode 100644 index 0000000000..caa37c23e2 --- /dev/null +++ b/cli/src/version/build-info.d.ts @@ -0,0 +1,9 @@ +// 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. +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 new file mode 100644 index 0000000000..6b913a28b1 --- /dev/null +++ b/cli/src/version/compat.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { compatString, difyCompat, evaluateCompat } from './compat.js' + +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 unsupported when server is below minimum', () => { + const v = evaluateCompat('1.5.9', range) + expect(v.status).toBe('unsupported') + expect(v.detail).toContain('1.5.9') + }) + + it('returns unsupported when server is above maximum', () => { + expect(evaluateCompat('2.0.0', range).status).toBe('unsupported') + }) + + 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 new file mode 100644 index 0000000000..b373ad0906 --- /dev/null +++ b/cli/src/version/compat.ts @@ -0,0 +1,58 @@ +import { parseRange, satisfies, 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' | 'unsupported' | '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 +} + +export function evaluateCompat( + serverVersion: string | undefined, + range: DifyCompat = difyCompat, +): CompatVerdict { + if (serverVersion === undefined || serverVersion === '') + return { status: 'unknown', detail: 'server version unknown' } + + const parsedServer = tryParse(serverVersion) + if (parsedServer === undefined) + return { status: 'unknown', detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver` } + + // The compat range is inclusive at both ends, exactly the format compatString prints. + const expr = `>=${range.minDify} <=${range.maxDify}` + const parsedRange = (() => { + try { + return parseRange(expr) + } + catch { + return undefined + } + })() + if (parsedRange === undefined) + return { status: 'unknown', detail: `compat range ${JSON.stringify(expr)} is not valid semver` } + + if (satisfies(parsedServer, parsedRange)) + return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` } + + return { status: 'unsupported', detail: `server ${serverVersion} outside [${range.minDify}, ${range.maxDify}]` } +} diff --git a/cli/src/version/info.test.ts b/cli/src/version/info.test.ts new file mode 100644 index 0000000000..f21a0cad0d --- /dev/null +++ b/cli/src/version/info.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { longVersion, shortVersion, userAgent } from './info.js' + +describe('version info', () => { + it('shortVersion returns the build-injected version string', () => { + expect(shortVersion()).toMatch(/^\d+\.\d+\.\d+(-[\w.]+)?$/) + }) + + it('longVersion includes commit, build date, channel, and compat range', () => { + 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', () => { + expect(userAgent()).toMatch(/^difyctl\/\S+ \(\S+; \S+; \S+\)$/) + }) +}) diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts new file mode 100644 index 0000000000..5f4b6245e9 --- /dev/null +++ b/cli/src/version/info.ts @@ -0,0 +1,31 @@ +import { compatString } from './compat.js' + +export type Channel = 'dev' | 'rc' | 'stable' + +export type VersionInfo = { + version: string + commit: string + buildDate: string + channel: Channel +} + +export const versionInfo: VersionInfo = { + version: __DIFYCTL_VERSION__, + commit: __DIFYCTL_COMMIT__, + buildDate: __DIFYCTL_BUILD_DATE__, + channel: __DIFYCTL_CHANNEL__ as Channel, +} + +export function shortVersion(): string { + return versionInfo.version +} + +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()}` +} + +export function userAgent(): string { + return `difyctl/${versionInfo.version} (${process.platform}; ${process.arch}; ${versionInfo.channel})` +} diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts new file mode 100644 index 0000000000..2eeaa32050 --- /dev/null +++ b/cli/src/version/nudge.test.ts @@ -0,0 +1,171 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' +import type { NudgeStore } from '../cache/nudge-store.js' +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 'vitest' +import { loadNudgeStore } from '../cache/nudge-store.js' +import { maybeNudgeCompat } from './nudge.js' + +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: 'SELF_HOSTED' } +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 + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) + store = await loadNudgeStore({ configDir: dir, now: fixedNow }) + }) + afterEach(async () => { + 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({ configDir: dir, now: () => yesterday }) + await tStore.markWarned(HOST) + const probe = vi.fn(async () => UNSUPPORTED) + const { emit, lines } = emitterSpy() + + const freshStore = await loadNudgeStore({ configDir: dir, 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: 'SELF_HOSTED' } 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 new file mode 100644 index 0000000000..f5c9866f92 --- /dev/null +++ b/cli/src/version/nudge.ts @@ -0,0 +1,68 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' +import type { NudgeStore } from '../cache/nudge-store.js' +import { colorScheme } from '../io/color.js' +import { difyCompat, evaluateCompat } from './compat.js' + +// 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) + if (verdict.status !== 'unsupported') + 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 new file mode 100644 index 0000000000..28e4335b12 --- /dev/null +++ b/cli/src/version/probe.test.ts @@ -0,0 +1,201 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' +import type { HostsBundle } from '../auth/hosts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { saveHosts } from '../auth/hosts.js' +import { ENV_CONFIG_DIR } from '../config/dir.js' +import { runVersionProbe } from './probe.js' + +function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle { + return { + current_host: 'cloud.dify.ai', + scheme: 'https', + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + ...overrides, + } as HostsBundle +} + +describe('runVersionProbe', () => { + it('returns skipped server + unknown compat when skipServer=true', async () => { + const report = await runVersionProbe({ + skipServer: true, + loadBundle: async () => bundle(), + 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, + loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }), + 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 bundle is missing', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: 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 bundle has empty current_host', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle({ current_host: '' }), + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(false) + expect(report.compat.status).toBe('unknown') + }) + + it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => { + const errReport = await runVersionProbe({ + skipServer: false, + loadBundle: 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, + loadBundle: 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, + loadBundle: async () => bundle(), + 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 unsupported when server version is out of range', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }), + }) + + expect(report.server.reachable).toBe(true) + expect(report.compat.status).toBe('unsupported') + }) + + it('returns unknown when server returns an empty version string', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }), + }) + + 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, + loadBundle: async () => bundle(), + 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 bundle scheme when host has no scheme', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }), + probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }), + }) + + 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 { + await saveHosts(configDir, { + current_host: url.host, + scheme: url.protocol.replace(':', ''), + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + }) + 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, + loadBundle: 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(process.platform) + expect(report.client.arch).toBe(process.arch) + }) +}) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts new file mode 100644 index 0000000000..25af8b3d61 --- /dev/null +++ b/cli/src/version/probe.ts @@ -0,0 +1,136 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' +import type { HostsBundle } from '../auth/hosts.js' +import type { CompatVerdict } from './compat.js' +import type { Channel } from './info.js' +import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js' +import { loadHosts } from '../auth/hosts.js' +import { resolveConfigDir } from '../config/dir.js' +import { createClient } from '../http/client.js' +import { hostWithScheme } from '../util/host.js' +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 loadBundle?: () => Promise<HostsBundle | undefined> + readonly probe?: MetaProbe +} + +const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir()) + +const defaultProbe: MetaProbe = async (endpoint) => { + const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + return new MetaClient(http).serverVersion() +} + +function buildClientBlock(): ClientBlock { + return { + version: versionInfo.version, + commit: versionInfo.commit, + buildDate: versionInfo.buildDate, + channel: versionInfo.channel, + platform: process.platform, + arch: process.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 loadBundle = opts.loadBundle ?? defaultLoadBundle + const probe = opts.probe ?? defaultProbe + + let bundle: HostsBundle | undefined + let loadFailed = false + try { + bundle = await loadBundle() + } + catch { + loadFailed = true + } + + if (bundle === undefined || bundle.current_host === '') { + const detail = loadFailed ? 'hosts file unreadable' : 'no host configured' + return { + client, + server: { endpoint: '', reachable: false }, + compat: compatBlock({ status: 'unknown', detail }), + } + } + + const endpoint = hostWithScheme(bundle.current_host, bundle.scheme) + + 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 new file mode 100644 index 0000000000..562d1daca9 --- /dev/null +++ b/cli/src/version/render.test.ts @@ -0,0 +1,163 @@ +import type { VersionReport } from './probe.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderVersionText } from './render.js' + +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 `[`). +// eslint-disable-next-line no-control-regex +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 RC 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 release candidate') + expect(text).toContain('install the stable channel') + }) + + 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: 'SELF_HOSTED' }, + compat: { + minDify: '1.6.0', + maxDify: '1.7.0', + status: 'unsupported', + 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 release candidate') + }) + + 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.js') + const report: VersionReport = { + client: baseClient({ channel: 'rc' }), + server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, + compat: { + minDify: '1.6.0', + maxDify: '1.7.0', + status: 'unsupported', + 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') + // RC warning lines also routed through yellow. + expect(colored).toContain('release candidate') + }) + }) + + 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 new file mode 100644 index 0000000000..1398622df2 --- /dev/null +++ b/cli/src/version/render.ts @@ -0,0 +1,59 @@ +import type { VersionReport } from './probe.js' +import { colorScheme } from '../io/color.js' + +const RC_WARNING_LINES = [ + 'WARNING: This build is a release candidate. It is in beta test, not stable,', + ' and may have bugs. For production use, install the stable channel.', +] as const + +export type RenderOptions = { + readonly color?: boolean +} + +const COMPAT_LABEL: Record<VersionReport['compat']['status'], string> = { + compatible: 'ok', + unsupported: 'incompatible', + 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}` + lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText) + + if (client.channel === 'rc') { + lines.push('') + for (const line of RC_WARNING_LINES) + lines.push(c.yellow(line)) + } + + return `${lines.join('\n')}\n` +} diff --git a/cli/src/workspace/resolver.ts b/cli/src/workspace/resolver.ts new file mode 100644 index 0000000000..225e65f666 --- /dev/null +++ b/cli/src/workspace/resolver.ts @@ -0,0 +1,34 @@ +import type { HostsBundle } from '../auth/hosts.js' +import { BaseError } from '../errors/base.js' +import { ErrorCode } from '../errors/codes.js' + +export type WorkspaceResolveInputs = { + readonly flag?: string + readonly env?: string + readonly bundle?: HostsBundle +} + +export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string { + if (truthy(inputs.flag)) + return inputs.flag + if (truthy(inputs.env)) + return inputs.env + const b = inputs.bundle + if (b !== undefined) { + if (truthy(b.workspace?.id)) + return b.workspace.id + if (b.available_workspaces !== undefined && b.available_workspaces.length > 0 + && truthy(b.available_workspaces[0]?.id)) { + return b.available_workspaces[0].id + } + } + throw new BaseError({ + code: ErrorCode.UsageMissingArg, + message: 'no workspace selected', + hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl auth use\'', + }) +} + +function truthy(v: string | undefined): v is string { + return v !== undefined && v !== '' +} diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts new file mode 100644 index 0000000000..39a0564db2 --- /dev/null +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -0,0 +1,170 @@ +export type Scenario + = | 'happy' + | 'sso' + | 'denied' + | 'expired' + | 'auth-expired' + | 'rate-limited' + | 'server-5xx' + | 'slow-down' + | 'stream-error' + | 'hitl-pause' + | 'hitl-resume' + | 'server-version-empty' + | 'server-version-unsupported' + +export type AccountFixture = { + id: string + email: string + name: string + is_external: boolean + current_workspace_id: string | null +} + +export type WorkspaceFixture = { + id: string + name: string + role: string + status: string + is_current: boolean +} + +export type AppFixture = { + id: string + workspace_id: string + workspace_name: string + name: string + mode: string + description: string + tags: { name: string }[] + created_at: string + updated_at: string + created_by_name: string + author?: string + service_api_enabled?: boolean + is_agent?: boolean + parameters?: Record<string, unknown> + input_schema?: Record<string, unknown> +} + +export type SessionFixture = { + id: string + prefix: string + client_id: string + device_label: string + created_at: string + last_used_at: string + expires_at: string +} + +export const ACCOUNT: AccountFixture = { + id: 'acct-1', + email: 'tester@dify.ai', + name: 'Test Tester', + is_external: false, + current_workspace_id: 'ws-1', +} + +export const WORKSPACES: WorkspaceFixture[] = [ + { id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', is_current: true }, + { id: 'ws-2', name: 'Other', role: 'normal', status: 'normal', is_current: false }, +] + +export const APPS: AppFixture[] = [ + { + id: 'app-1', + workspace_id: 'ws-1', + workspace_name: 'Default', + name: 'Greeter', + mode: 'chat', + description: 'A simple greeting bot', + tags: [{ name: 'demo' }], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + created_by_name: 'tester', + author: 'tester', + service_api_enabled: true, + is_agent: false, + parameters: { + opening_statement: 'Hi, I am Greeter.', + suggested_questions: ['What is your name?'], + user_input_form: [ + { type: 'text-input', variable: 'name', label: 'Your name', required: true }, + ], + system_parameters: { image_file_size_limit: 10 }, + }, + }, + { + id: 'app-4', + workspace_id: 'ws-2', + workspace_name: 'Other', + name: 'Researcher', + mode: 'agent-chat', + description: 'An agent that researches', + tags: [], + created_at: '2026-02-01T00:00:00Z', + updated_at: '2026-02-02T00:00:00Z', + created_by_name: 'tester', + author: 'tester', + service_api_enabled: false, + is_agent: true, + }, + { + id: 'app-2', + workspace_id: 'ws-1', + workspace_name: 'Default', + name: 'Workflow', + mode: 'workflow', + description: '', + tags: [], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + created_by_name: 'tester', + author: 'tester', + service_api_enabled: false, + }, + { + id: 'app-3', + workspace_id: 'ws-2', + workspace_name: 'Other', + name: 'OtherWS Bot', + mode: 'chat', + description: '', + tags: [{ name: 'wip' }], + created_at: '2026-01-03T00:00:00Z', + updated_at: '2026-01-04T00:00:00Z', + created_by_name: 'admin', + author: 'admin', + service_api_enabled: false, + }, +] + +export const SESSIONS: SessionFixture[] = [ + { + id: 'tok-1', + prefix: 'dfoa', + client_id: 'difyctl', + device_label: 'difyctl on laptop', + created_at: '2026-05-01T00:00:00Z', + last_used_at: '2026-05-08T00:00:00Z', + expires_at: '2026-08-01T00:00:00Z', + }, + { + id: 'tok-2', + prefix: 'dfoa', + client_id: 'difyctl', + device_label: 'difyctl on desktop', + created_at: '2026-04-15T00:00:00Z', + last_used_at: '2026-05-07T00:00:00Z', + expires_at: '2026-07-15T00:00:00Z', + }, + { + id: 'tok-3', + prefix: 'dfoa', + client_id: 'cloud-console', + device_label: 'web ui', + created_at: '2026-05-05T00:00:00Z', + last_used_at: '2026-05-08T00:00:00Z', + expires_at: '2026-08-05T00:00:00Z', + }, +] diff --git a/cli/test/fixtures/dify-mock/server.test.ts b/cli/test/fixtures/dify-mock/server.test.ts new file mode 100644 index 0000000000..c14762715e --- /dev/null +++ b/cli/test/fixtures/dify-mock/server.test.ts @@ -0,0 +1,280 @@ +import type { DifyMock } from './server.js' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { startMock } from './server.js' + +describe('dify-mock fixture server', () => { + let mock: DifyMock + + beforeEach(async () => { + mock = await startMock() + }) + + afterEach(async () => { + await mock.stop() + }) + + it('listens on an ephemeral port', () => { + expect(mock.port).toBeGreaterThan(0) + expect(mock.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/) + }) + + it('GET /healthz returns 200 without auth', async () => { + const r = await fetch(`${mock.url}/healthz`) + expect(r.status).toBe(200) + expect(await r.json()).toEqual({ ok: true }) + }) + + it('rejects /openapi/v1/* without Authorization header', async () => { + const r = await fetch(`${mock.url}/openapi/v1/workspaces`) + expect(r.status).toBe(401) + }) + + it('rejects malformed Bearer tokens', async () => { + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { + headers: { Authorization: 'Bearer wrongprefix_abc' }, + }) + expect(r.status).toBe(401) + }) + + it('accepts dfoa_ tokens (community/account)', async () => { + const r = await fetch(`${mock.url}/openapi/v1/workspaces`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + 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' }, + }) + 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' }, + }) + expect(r.status).toBe(200) + const body = await r.json() as { + workspaces: Array<{ id: string, name: string, role: string, status: string, current: boolean }> + } + expect(body.workspaces).toHaveLength(2) + expect(body.workspaces[0]?.id).toBe('ws-1') + expect(body.workspaces[0]?.status).toBe('normal') + expect(body.workspaces[0]?.current).toBe(true) + expect(body.workspaces[1]?.current).toBe(false) + }) + + 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' }, + }) + expect(r.status).toBe(200) + const body = await r.json() as { workspaces: unknown[] } + expect(body.workspaces).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' }, + }) + expect(r.status).toBe(200) + const body = await r.json() as { + subject_type: string + account: { email: string } | null + workspaces: Array<{ id: string }> + default_workspace_id: string + } + 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('ws-1') + }) + + it('GET /openapi/v1/apps respects ?mode filter', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=ws-1&mode=workflow`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + const body = await r.json() as { data: Array<{ mode: string }>, total: number } + expect(body.data).toHaveLength(1) + expect(body.data[0]?.mode).toBe('workflow') + expect(body.total).toBe(1) + }) + + it('GET /openapi/v1/apps scopes by workspace_id', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps?workspace_id=ws-2`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + 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/describe returns 404 for unknown id', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/nope/describe?workspace_id=ws-1`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + expect(r.status).toBe(404) + }) + + it('GET /openapi/v1/apps/:id/describe returns the app for known id', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=ws-1`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + 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`, { + method: 'POST', + headers: { + 'Authorization': 'Bearer dfoa_test', + '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: "') + }) + + 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`, { + method: 'POST', + headers: { + 'Authorization': 'Bearer dfoa_test', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ inputs: { x: 1 } }), + }) + expect(r.status).toBe(200) + expect(r.headers.get('content-type')).toContain('text/event-stream') + const text = await r.text() + expect(text).toContain('"workflow_finished"') + }) + + it('GET /openapi/v1/apps/:id/describe?fields=info returns slim payload', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=ws-1&fields=info`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + expect(r.status).toBe(200) + const body = await r.json() as { info: { id: string }, parameters: unknown, input_schema: unknown } + expect(body.info.id).toBe('app-1') + expect(body.parameters).toBeNull() + expect(body.input_schema).toBeNull() + }) + + it('GET /openapi/v1/apps/:id/describe full returns parameters when present', async () => { + const r = await fetch(`${mock.url}/openapi/v1/apps/app-1/describe?workspace_id=ws-1`, { + headers: { Authorization: 'Bearer dfoa_test' }, + }) + 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.') + }) + + it('POST /openapi/v1/oauth/device/code returns RFC 8628 fields', async () => { + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'difyctl', device_label: 'difyctl on host' }), + }) + expect(r.status).toBe(200) + const body = await r.json() as Record<string, unknown> + expect(body.device_code).toBeDefined() + expect(body.user_code).toBeDefined() + expect(body.interval).toBeDefined() + }) + + it('POST /openapi/v1/oauth/device/token returns Dify token envelope', async () => { + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: 'difyctl', device_code: 'devcode-1' }), + }) + expect(r.status).toBe(200) + const body = await r.json() as { token: string, subject_type: string, account?: { email: string } } + expect(body.token).toMatch(/^dfoa_/) + expect(body.subject_type).toBe('account') + expect(body.account?.email).toBe('tester@dify.ai') + }) + + it('scenario:sso returns external_sso envelope with dfoe_ token', async () => { + mock.setScenario('sso') + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: 'devcode-1' }), + }) + expect(r.status).toBe(200) + const body = await r.json() as { token: string, subject_type: string, subject_email: string } + expect(body.token).toMatch(/^dfoe_/) + expect(body.subject_type).toBe('external_sso') + expect(body.subject_email).toBe('sso@dify.ai') + }) + + it('scenario:denied returns access_denied on token poll', async () => { + mock.setScenario('denied') + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: 'devcode-1' }), + }) + expect(r.status).toBe(400) + const body = await r.json() as { error: string } + expect(body.error).toBe('access_denied') + }) + + it('scenario:expired returns expired_token on token poll', async () => { + mock.setScenario('expired') + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: 'devcode-1' }), + }) + expect(r.status).toBe(400) + const body = await r.json() as { error: string } + expect(body.error).toBe('expired_token') + }) + + it('scenario:slow-down returns slow_down on token poll', async () => { + mock.setScenario('slow-down') + const r = await fetch(`${mock.url}/openapi/v1/oauth/device/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ device_code: 'devcode-1' }), + }) + expect(r.status).toBe(400) + const body = await r.json() as { error: string } + expect(body.error).toBe('slow_down') + }) + + 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' }, + }) + expect(r.status).toBe(401) + }) + + 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' }, + }) + expect(r.status).toBe(429) + expect(r.headers.get('retry-after')).toBe('1') + }) + + 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' }, + }) + expect(r.status).toBe(503) + }) +}) diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts new file mode 100644 index 0000000000..b4c5ac6426 --- /dev/null +++ b/cli/test/fixtures/dify-mock/server.ts @@ -0,0 +1,404 @@ +import type { AddressInfo } from 'node:net' +import type { Scenario } from './scenarios.js' +import { serve } from '@hono/node-server' +import { Hono } from 'hono' +import { ACCOUNT, APPS, SESSIONS, WORKSPACES } from './scenarios.js' + +export type DifyMockOptions = { + scenario?: Scenario + port?: number +} + +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 */ + lastRunBody: Record<string, unknown> | null + /** Number of times POST /apps/:id/files/upload was called */ + uploadCallCount: number +} + +const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/ + +function unauthorized() { + return Response.json( + { error: { code: 'auth_expired', message: 'invalid or expired token' } }, + { status: 401 }, + ) +} + +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 { + if (mode === 'workflow') { + return sseChunks([ + { event: 'workflow_started', data: { id: 'wf-run-1', workflow_id: 'wf-1' } }, + { event: 'node_started', data: { id: 'n1', title: 'first' } }, + { event: 'node_finished', data: { id: 'n1', status: 'succeeded' } }, + { event: 'workflow_finished', data: { id: 'wf-run-1', workflow_id: 'wf-1', data: { id: 'wf-run-1', status: 'succeeded', outputs: { result: `echo: ${query}` } } } }, + ]) + } + if (mode === 'completion') { + 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: {} } }, + ]) + } + const evt = isAgent ? 'agent_message' : 'message' + const events: { event: string, data: Record<string, unknown> }[] = [ + { event: evt, data: { message_id: 'msg-1', conversation_id: 'conv-1', mode, answer: 'echo: ' } }, + { event: evt, data: { answer: query } }, + ] + 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: {} } }) + return sseChunks(events) +} + +function hitlPauseResponse(): 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' } }, + { + event: 'human_input_required', + data: { + task_id: 'task-hitl-1', + workflow_run_id: 'wf-run-hitl-1', + data: { + form_id: 'form-hitl-1', + node_id: 'n1', + node_title: 'First Node', + form_content: 'Please provide input', + inputs: [{ output_variable_name: 'name' }], + actions: [{ id: 'submit', title: 'Submit' }], + display_in_ui: false, + form_token: 'ft-hitl-1', + resolved_default_values: { name: 'Alice' }, + expiration_time: 9999999999, + }, + }, + }, + { event: 'workflow_paused', data: { reasons: [] } }, + ]) +} + +function hitlResumedResponse(): string { + return sseChunks([ + { event: 'node_started', data: { id: 'n2', title: 'After Resume' } }, + { event: 'node_finished', data: { id: 'n2', status: 'succeeded' } }, + { + event: 'workflow_finished', + data: { + id: 'wf-run-hitl-1', + workflow_id: 'wf-1', + data: { id: 'wf-run-hitl-1', status: 'succeeded', outputs: { result: 'echo: resumed' } }, + }, + }, + ]) +} + +export type MockState = { + lastRunBody: Record<string, unknown> | null + uploadCallCount: number +} + +export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { + const app = new Hono() + + app.get('/healthz', c => c.json({ ok: true })) + + app.use('*', async (c, next) => { + if (c.req.path === '/healthz') { + await next() + return + } + if (c.req.path.startsWith('/openapi/v1/oauth/')) { + await next() + return + } + if (c.req.path === '/openapi/v1/_version') { + await next() + return + } + const auth = c.req.header('Authorization') ?? '' + if (!TOKEN_RE.test(auth)) + return unauthorized() + const scenario = getScenario() + if (scenario === 'auth-expired') + return unauthorized() + await next() + }) + + app.get('/openapi/v1/_version', (c) => { + const scenario = getScenario() + if (scenario === 'server-version-empty') + return c.json({ version: '', edition: 'SELF_HOSTED' }) + if (scenario === 'server-version-unsupported') + return c.json({ version: '99.0.0', edition: 'SELF_HOSTED' }) + return c.json({ version: '1.6.4', edition: 'CLOUD' }) + }) + + app.use('*', async (c, next) => { + const scenario = getScenario() + if (scenario === 'rate-limited') { + return c.json( + { error: { code: 'rate_limited', message: 'too many requests' } }, + { status: 429, headers: { 'retry-after': '1' } }, + ) + } + if (scenario === 'server-5xx') { + return c.json( + { error: { code: 'server_5xx', message: 'upstream broken' } }, + { status: 503 }, + ) + } + await next() + }) + + app.get('/openapi/v1/account', (c) => { + const scenario = getScenario() + if (scenario === 'sso') { + return c.json({ + subject_type: 'external_sso', + subject_email: 'sso@dify.ai', + subject_issuer: 'https://issuer.example', + account: null, + workspaces: [], + default_workspace_id: null, + }) + } + return c.json({ + subject_type: 'account', + subject_email: ACCOUNT.email, + account: { id: ACCOUNT.id, email: ACCOUNT.email, name: ACCOUNT.name }, + workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + default_workspace_id: 'ws-1', + }) + }) + + app.get('/openapi/v1/account/sessions', (c) => { + const page = Number(c.req.query('page') ?? '1') + const limit = Number(c.req.query('limit') ?? '100') + const total = SESSIONS.length + const start = (page - 1) * limit + const slice = SESSIONS.slice(start, start + limit) + return c.json({ + page, + limit, + total, + has_more: page * limit < total, + data: slice, + }) + }) + + app.delete('/openapi/v1/account/sessions/self', () => + Response.json({ status: 'revoked' }, { status: 200 })) + + 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 Response.json({ status: 'revoked' }, { status: 200 }) + }) + + app.get('/openapi/v1/workspaces', (c) => { + if (getScenario() === 'sso') + return c.json({ workspaces: [] }) + return c.json({ + workspaces: WORKSPACES.map(w => ({ + id: w.id, + name: w.name, + role: w.role, + status: w.status, + current: w.is_current, + })), + }) + }) + + app.get('/openapi/v1/apps', (c) => { + const page = Number(c.req.query('page') ?? '1') + const limit = Number(c.req.query('limit') ?? '20') + const mode = c.req.query('mode') + const tag = c.req.query('tag') + const name = c.req.query('name') + const workspaceId = c.req.query('workspace_id') ?? 'ws-1' + let filtered = APPS.filter(a => a.workspace_id === workspaceId) + if (mode !== undefined && mode !== '') + filtered = filtered.filter(a => a.mode === mode) + if (tag !== undefined && tag !== '') + filtered = filtered.filter(a => a.tags.some(t => t.name === tag)) + if (name !== undefined && name !== '') + filtered = filtered.filter(a => a.name.includes(name)) + const total = filtered.length + const start = (page - 1) * limit + const slice = filtered.slice(start, start + limit) + return c.json({ + page, + limit, + total, + has_more: page * limit < total, + data: slice, + }) + }) + + app.get('/openapi/v1/apps/:id/describe', (c) => { + const id = c.req.param('id') + const wsId = c.req.query('workspace_id') + const fieldsRaw = c.req.query('fields') ?? '' + const fields = fieldsRaw === '' ? [] : fieldsRaw.split(',').map(s => s.trim()).filter(s => s !== '') + 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 }) + 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') + return c.json({ + info: wantInfo + ? { + id: app.id, + name: app.name, + description: app.description, + mode: app.mode, + author: app.author ?? '', + tags: app.tags, + updated_at: app.updated_at, + service_api_enabled: app.service_api_enabled ?? false, + is_agent: app.is_agent ?? false, + } + : null, + parameters: wantParams ? (app.parameters ?? null) : null, + input_schema: wantInputSchema ? (app.input_schema ?? null) : null, + }) + }) + + app.post('/openapi/v1/apps/:id/run', async (c) => { + const id = c.req.param('id') + const body = await c.req.json() as { query?: string, inputs?: unknown } + 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 === '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' } }) + } + const sse = streamingRunResponse(app.mode, query, isAgent) + return new Response(sse, { status: 200, headers: { 'content-type': 'text/event-stream' } }) + }) + + app.post('/openapi/v1/apps/:id/files/upload', 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 }) + const ext = file.name.split('.').pop() ?? null + return Response.json( + { + id: 'upload-file-1', + name: file.name, + size: file.size, + extension: ext, + mime_type: file.type || null, + created_by: 'acct-1', + }, + { status: 201 }, + ) + }) + + app.post('/openapi/v1/apps/:id/tasks/:taskId/stop', (c) => { + return c.json({ result: 'success' }) + }) + + app.post('/openapi/v1/apps/:id/form/human_input/:formToken', (c) => { + 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.post('/openapi/v1/oauth/device/code', c => + 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() + if (scenario === 'denied') + return c.json({ error: 'access_denied', error_description: 'user rejected' }, { status: 400 }) + if (scenario === 'expired') + return c.json({ error: 'expired_token', error_description: 'device_code expired' }, { status: 400 }) + if (scenario === 'slow-down') + return c.json({ error: 'slow_down', error_description: 'increase interval' }, { status: 400 }) + if (scenario === 'sso') { + return c.json({ + token: 'dfoe_test', + subject_type: 'external_sso', + subject_email: 'sso@dify.ai', + subject_issuer: 'https://issuer.example', + token_id: 'tok-sso-1', + }) + } + return c.json({ + token: 'dfoa_test', + subject_type: 'account', + account: ACCOUNT, + workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })), + default_workspace_id: 'ws-1', + token_id: 'tok-1', + }) + }) + + return app +} + +export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> { + let scenario: Scenario = opts.scenario ?? 'happy' + const state: MockState = { lastRunBody: null, uploadCallCount: 0 } + const app = buildApp(() => scenario, state) + return new Promise((resolve, reject) => { + const server = serve({ + fetch: app.fetch, + port: opts.port ?? 0, + hostname: '127.0.0.1', + overrideGlobalObjects: false, + }) + server.on('listening', () => { + const addr = server.address() as AddressInfo + resolve({ + url: `http://127.0.0.1:${addr.port}`, + port: addr.port, + scenario, + setScenario(s) { scenario = s }, + stop() { + return new Promise<void>((res, rej) => { + server.close(err => err ? rej(err) : res()) + }) + }, + get lastRunBody() { return state.lastRunBody }, + get uploadCallCount() { return state.uploadCallCount }, + }) + }) + server.on('error', reject) + }) +} diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts new file mode 100644 index 0000000000..45649f73cd --- /dev/null +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' + +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. +const noPkg = () => ({}) + +describe('resolveBuildInfo', () => { + it('uses env values when fully populated', () => { + const info = resolveBuildInfo({ + env: { + DIFYCTL_VERSION: '1.2.3', + DIFYCTL_COMMIT: 'abcdef0123456789', + DIFYCTL_BUILD_DATE: '2026-01-01T00:00:00.000Z', + DIFYCTL_CHANNEL: 'stable', + }, + git: () => 'should-not-be-called', + now: fixedNow, + pkg: noPkg, + }) + expect(info).toStrictEqual({ + version: '1.2.3', + commit: 'abcdef0123456789', + buildDate: '2026-01-01T00:00:00.000Z', + channel: 'stable', + minDify: '0.0.0', + maxDify: '0.0.0', + }) + }) + + it('falls back to git probes when env unset', () => { + const calls: string[] = [] + const git = (cmd: string) => { + calls.push(cmd) + if (cmd.startsWith('git describe')) + return 'v1.0.0-5-gabc1234-dirty' + if (cmd.startsWith('git rev-parse')) + return '1234567890abcdef' + return null + } + const info = resolveBuildInfo({ env: {}, git, now: fixedNow, pkg: noPkg }) + expect(info).toStrictEqual({ + version: 'v1.0.0-5-gabc1234-dirty', + 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', + ]) + }) + + it('uses string defaults when env unset, git unavailable, and package.json empty', () => { + const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg: noPkg }) + expect(info).toStrictEqual({ + version: '0.0.0-dev', + commit: 'none', + buildDate: '2026-05-09T12:00:00.000Z', + channel: 'dev', + minDify: '0.0.0', + maxDify: '0.0.0', + }) + }) + + it('throws on invalid channel', () => { + expect(() => + resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'beta' }, git: noGit, now: fixedNow, pkg: noPkg }), + ).toThrow(/invalid DIFYCTL_CHANNEL: beta/) + }) + + it('throws on removed nightly channel', () => { + expect(() => + resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'nightly' }, git: noGit, now: fixedNow, pkg: noPkg }), + ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) + }) + + it('accepts rc channel', () => { + 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', + }, + git: noGit, + now: fixedNow, + pkg: noPkg, + }) + expect(info.channel).toBe('rc') + }) + + it('mixes env and git fallbacks per field', () => { + const git = (cmd: string) => (cmd.startsWith('git describe') ? 'v9.9.9' : null) + const info = resolveBuildInfo({ + env: { DIFYCTL_COMMIT: 'pinned-sha' }, + git, + now: fixedNow, + pkg: noPkg, + }) + expect(info.version).toBe('v9.9.9') + expect(info.commit).toBe('pinned-sha') + 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' } }) + 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' } }) + const info = resolveBuildInfo({ + env: { + DIFYCTL_MIN_DIFY: '2.0.0', + DIFYCTL_MAX_DIFY: '2.1.0', + 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') + }) +}) diff --git a/cli/test/setup.ts b/cli/test/setup.ts new file mode 100644 index 0000000000..292dff0d82 --- /dev/null +++ b/cli/test/setup.ts @@ -0,0 +1,6 @@ +(globalThis as unknown as Record<string, string>).__DIFYCTL_VERSION__ = '0.0.0-test'; +(globalThis as unknown as Record<string, string>).__DIFYCTL_COMMIT__ = '0000000'; +(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/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000000..dc04c33f30 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@dify/tsconfig/node.json", + "compilerOptions": { + "rootDir": "src", + "types": ["node"], + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test", "node_modules", "**/*.test.ts"] +} diff --git a/cli/vite.config.ts b/cli/vite.config.ts new file mode 100644 index 0000000000..f54f035fdc --- /dev/null +++ b/cli/vite.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite-plus' +import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js' + +const buildInfo = resolveBuildInfo() + +export default defineConfig({ + pack: { + entry: ['src/index.ts', 'src/commands/**/*.ts', 'src/framework/**/*.ts'], + format: ['esm'], + fixedExtension: false, + dts: true, + clean: true, + sourcemap: true, + treeshake: false, + outDir: 'dist', + target: 'node22', + define: { + __DIFYCTL_VERSION__: JSON.stringify(buildInfo.version), + __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: { + environment: 'node', + setupFiles: ['./test/setup.ts'], + include: ['test/**/*.test.ts', 'src/**/*.test.ts', 'scripts/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'json'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/types/**'], + }, + }, +}) diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index df4c471d11..aaab029807 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -221,6 +221,7 @@ QUEUE_MONITOR_ALERT_EMAILS= QUEUE_MONITOR_INTERVAL=30 SWAGGER_UI_ENABLED=false SWAGGER_UI_PATH=/swagger-ui.html +OPENAPI_ENABLED=false DSL_EXPORT_ENCRYPT_DATASET_ID=true DATASET_MAX_SEGMENTS_PER_REQUEST=0 ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template index 64c720ca2b..6f17d0a37b 100644 --- a/docker/nginx/conf.d/default.conf.template +++ b/docker/nginx/conf.d/default.conf.template @@ -29,6 +29,11 @@ server { include proxy.conf; } + location /openapi { + proxy_pass http://api:5001; + include proxy.conf; + } + location /files { proxy_pass http://api:5001; include proxy.conf; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1380ed67d2..d939eb4d03 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -22,6 +22,10 @@ export default antfu( '!packages/**', '!web/**', '!e2e/**', + '!cli/**', + 'cli/context/**', + 'cli/docs/**', + 'cli/oclif.manifest.json', '!eslint.config.mjs', '!package.json', '!pnpm-workspace.yaml', diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts new file mode 100644 index 0000000000..c445ab877b --- /dev/null +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -0,0 +1,502 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' + +import { + zDeleteAccountSessionsBySessionIdPath, + zDeleteAccountSessionsBySessionIdResponse, + zDeleteAccountSessionsSelfResponse, + zGetAccountResponse, + zGetAccountSessionsResponse, + zGetAppsByAppIdDescribePath, + zGetAppsByAppIdDescribeQuery, + zGetAppsByAppIdDescribeResponse, + zGetAppsByAppIdFormHumanInputByFormTokenPath, + zGetAppsByAppIdFormHumanInputByFormTokenResponse, + zGetAppsByAppIdTasksByTaskIdEventsPath, + zGetAppsByAppIdTasksByTaskIdEventsResponse, + zGetAppsQuery, + zGetAppsResponse, + zGetHealthResponse, + zGetOauthDeviceLookupQuery, + zGetOauthDeviceLookupResponse, + zGetPermittedExternalAppsResponse, + zGetVersionResponse, + zGetWorkspacesByWorkspaceIdPath, + zGetWorkspacesByWorkspaceIdResponse, + zGetWorkspacesResponse, + zPostAppsByAppIdFilesUploadPath, + zPostAppsByAppIdFilesUploadResponse, + zPostAppsByAppIdFormHumanInputByFormTokenBody, + zPostAppsByAppIdFormHumanInputByFormTokenPath, + zPostAppsByAppIdFormHumanInputByFormTokenResponse, + zPostAppsByAppIdRunBody, + zPostAppsByAppIdRunPath, + zPostAppsByAppIdRunResponse, + zPostAppsByAppIdTasksByTaskIdStopPath, + zPostAppsByAppIdTasksByTaskIdStopResponse, + zPostOauthDeviceApproveBody, + zPostOauthDeviceApproveResponse, + zPostOauthDeviceCodeBody, + zPostOauthDeviceCodeResponse, + zPostOauthDeviceDenyBody, + zPostOauthDeviceDenyResponse, + zPostOauthDeviceTokenBody, + zPostOauthDeviceTokenResponse, +} from './zod.gen' + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getHealth', + path: '/_health', + tags: ['openapi'], + }) + .output(zGetHealthResponse) + +export const health = { + get, +} + +export const get2 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getVersion', + path: '/_version', + tags: ['openapi'], + }) + .output(zGetVersionResponse) + +export const version = { + get: get2, +} + +export const delete_ = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAccountSessionsSelf', + path: '/account/sessions/self', + tags: ['openapi'], + }) + .output(zDeleteAccountSessionsSelfResponse) + +export const self = { + delete: delete_, +} + +export const delete2 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteAccountSessionsBySessionId', + path: '/account/sessions/{session_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zDeleteAccountSessionsBySessionIdPath })) + .output(zDeleteAccountSessionsBySessionIdResponse) + +export const bySessionId = { + delete: delete2, +} + +export const get3 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAccountSessions', + path: '/account/sessions', + tags: ['openapi'], + }) + .output(zGetAccountSessionsResponse) + +export const sessions = { + get: get3, + self, + bySessionId, +} + +export const get4 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAccount', + path: '/account', + tags: ['openapi'], + }) + .output(zGetAccountResponse) + +export const account = { + get: get4, + sessions, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdDescribe', + path: '/apps/{app_id}/describe', + tags: ['openapi'], + }) + .input( + z.object({ + params: zGetAppsByAppIdDescribePath, + query: zGetAppsByAppIdDescribeQuery.optional(), + }), + ) + .output(zGetAppsByAppIdDescribeResponse) + +export const describe = { + get: get5, +} + +/** + * Upload a file to use as an input variable when running the app + */ +export const post = oc + .route({ + description: 'Upload a file to use as an input variable when running the app', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdFilesUpload', + path: '/apps/{app_id}/files/upload', + successStatus: 201, + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdFilesUploadPath })) + .output(zPostAppsByAppIdFilesUploadResponse) + +export const upload = { + post, +} + +export const files = { + upload, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get6 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdFormHumanInputByFormToken', + path: '/apps/{app_id}/form/human_input/{form_token}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdFormHumanInputByFormTokenPath })) + .output(zGetAppsByAppIdFormHumanInputByFormTokenResponse) + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post2 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdFormHumanInputByFormToken', + path: '/apps/{app_id}/form/human_input/{form_token}', + tags: ['openapi'], + }) + .input( + z.object({ + body: zPostAppsByAppIdFormHumanInputByFormTokenBody, + params: zPostAppsByAppIdFormHumanInputByFormTokenPath, + }), + ) + .output(zPostAppsByAppIdFormHumanInputByFormTokenResponse) + +export const byFormToken = { + get: get6, + post: post2, +} + +export const humanInput = { + byFormToken, +} + +export const form = { + humanInput, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post3 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdRun', + path: '/apps/{app_id}/run', + tags: ['openapi'], + }) + .input(z.object({ body: zPostAppsByAppIdRunBody, params: zPostAppsByAppIdRunPath })) + .output(zPostAppsByAppIdRunResponse) + +export const run = { + post: post3, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get7 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getAppsByAppIdTasksByTaskIdEvents', + path: '/apps/{app_id}/tasks/{task_id}/events', + tags: ['openapi'], + }) + .input(z.object({ params: zGetAppsByAppIdTasksByTaskIdEventsPath })) + .output(zGetAppsByAppIdTasksByTaskIdEventsResponse) + +export const events = { + get: get7, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post4 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postAppsByAppIdTasksByTaskIdStop', + path: '/apps/{app_id}/tasks/{task_id}/stop', + tags: ['openapi'], + }) + .input(z.object({ params: zPostAppsByAppIdTasksByTaskIdStopPath })) + .output(zPostAppsByAppIdTasksByTaskIdStopResponse) + +export const stop = { + post: post4, +} + +export const byTaskId = { + events, + stop, +} + +export const tasks = { + byTaskId, +} + +export const byAppId = { + describe, + files, + form, + run, + tasks, +} + +export const get8 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getApps', + path: '/apps', + tags: ['openapi'], + }) + .input(z.object({ query: zGetAppsQuery })) + .output(zGetAppsResponse) + +export const apps = { + get: get8, + byAppId, +} + +export const post5 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postOauthDeviceApprove', + path: '/oauth/device/approve', + tags: ['openapi'], + }) + .input(z.object({ body: zPostOauthDeviceApproveBody })) + .output(zPostOauthDeviceApproveResponse) + +export const approve = { + post: post5, +} + +export const post6 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postOauthDeviceCode', + path: '/oauth/device/code', + tags: ['openapi'], + }) + .input(z.object({ body: zPostOauthDeviceCodeBody })) + .output(zPostOauthDeviceCodeResponse) + +export const code = { + post: post6, +} + +export const post7 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postOauthDeviceDeny', + path: '/oauth/device/deny', + tags: ['openapi'], + }) + .input(z.object({ body: zPostOauthDeviceDenyBody })) + .output(zPostOauthDeviceDenyResponse) + +export const deny = { + post: post7, +} + +export const get9 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getOauthDeviceLookup', + path: '/oauth/device/lookup', + tags: ['openapi'], + }) + .input(z.object({ query: zGetOauthDeviceLookupQuery })) + .output(zGetOauthDeviceLookupResponse) + +export const lookup = { + get: get9, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post8 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postOauthDeviceToken', + path: '/oauth/device/token', + tags: ['openapi'], + }) + .input(z.object({ body: zPostOauthDeviceTokenBody })) + .output(zPostOauthDeviceTokenResponse) + +export const token = { + post: post8, +} + +export const device = { + approve, + code, + deny, + lookup, + token, +} + +export const oauth = { + device, +} + +export const get10 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getPermittedExternalApps', + path: '/permitted-external-apps', + tags: ['openapi'], + }) + .output(zGetPermittedExternalAppsResponse) + +export const permittedExternalApps = { + get: get10, +} + +export const get11 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesByWorkspaceId', + path: '/workspaces/{workspace_id}', + tags: ['openapi'], + }) + .input(z.object({ params: zGetWorkspacesByWorkspaceIdPath })) + .output(zGetWorkspacesByWorkspaceIdResponse) + +export const byWorkspaceId = { + get: get11, +} + +export const get12 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspaces', + path: '/workspaces', + tags: ['openapi'], + }) + .output(zGetWorkspacesResponse) + +export const workspaces = { + get: get12, + byWorkspaceId, +} + +export const contract = { + health, + version, + account, + apps, + oauth, + permittedExternalApps, + workspaces, +} diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts new file mode 100644 index 0000000000..b0ce8f427b --- /dev/null +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -0,0 +1,640 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/openapi/v1` | (string & {}) +} + +export type AccountPayload = { + email: string + id: string + name: string +} + +export type AccountResponse = { + account?: AccountPayload + default_workspace_id?: string | null + subject_email?: string | null + subject_issuer?: string | null + subject_type: string + workspaces?: Array<WorkspacePayload> +} + +export type AppDescribeInfo = { + author?: string | null + description?: string | null + id: string + is_agent?: boolean + mode: string + name: string + service_api_enabled: boolean + tags?: Array<TagItem> + updated_at?: string | null +} + +export type AppDescribeQuery = { + fields?: Array<string> | null + workspace_id?: string | null +} + +export type AppDescribeResponse = { + info?: AppDescribeInfo + input_schema?: { + [key: string]: unknown + } | null + parameters?: { + [key: string]: unknown + } | null +} + +export type AppInfoResponse = { + author?: string | null + description?: string | null + id: string + mode: string + name: string + tags?: Array<TagItem> +} + +export type AppListQuery = { + limit?: number + mode?: AppMode + name?: string | null + page?: number + tag?: string | null + workspace_id: string +} + +export type AppListResponse = { + data: Array<AppListRow> + has_more: boolean + limit: number + page: number + total: number +} + +export type AppListRow = { + created_by_name?: string | null + description?: string | null + id: string + mode: AppMode + name: string + tags?: Array<TagItem> + updated_at?: string | null + workspace_id?: string | null + workspace_name?: string | null +} + +export type AppMode + = | 'advanced-chat' + | 'agent-chat' + | 'channel' + | 'chat' + | 'completion' + | 'rag-pipeline' + | 'workflow' + +export type AppRunRequest = { + auto_generate_name?: boolean + conversation_id?: string | null + files?: Array<{ + [key: string]: unknown + }> | null + inputs: { + [key: string]: unknown + } + query?: string | null + workflow_id?: string | null + workspace_id?: string | null +} + +export type DeviceCodeRequest = { + client_id: string + device_label: string +} + +export type DeviceCodeResponse = { + device_code: string + expires_in: number + interval: number + user_code: string + verification_uri: string +} + +export type DeviceLookupQuery = { + user_code: string +} + +export type DeviceLookupResponse = { + client_id?: string | null + expires_in_remaining?: number + valid: boolean +} + +export type DeviceMutateRequest = { + user_code: string +} + +export type DeviceMutateResponse = { + status: string +} + +export type DevicePollRequest = { + client_id: string + device_code: string +} + +export type FileResponse = { + conversation_id?: string | null + created_at?: number | null + created_by?: string | null + extension?: string | null + file_key?: string | null + id: string + mime_type?: string | null + name: string + original_url?: string | null + preview_url?: string | null + size: number + source_url?: string | null + tenant_id?: string | null + user_id?: string | null +} + +export type HumanInputFormSubmitPayload = { + action: string + inputs: { + [key: string]: JsonValue + } +} + +export type JsonValue = unknown + +export type MessageMetadata = { + retriever_resources?: Array<{ + [key: string]: unknown + }> + usage?: UsageInfo +} + +export type PermittedExternalAppsListQuery = { + limit?: number + mode?: AppMode + name?: string | null + page?: number +} + +export type PermittedExternalAppsListResponse = { + data: Array<AppListRow> + has_more: boolean + limit: number + page: number + total: number +} + +export type RevokeResponse = { + status: string +} + +export type ServerVersionResponse = { + edition: 'CLOUD' | 'SELF_HOSTED' + version: string +} + +export type SessionListResponse = { + data: Array<SessionRow> + has_more: boolean + limit: number + page: number + total: number +} + +export type SessionRow = { + client_id: string + created_at?: string | null + device_label: string + expires_at?: string | null + id: string + last_used_at?: string | null + prefix: string +} + +export type TagItem = { + name: string +} + +export type UsageInfo = { + completion_tokens?: number + prompt_tokens?: number + total_tokens?: number +} + +export type WorkflowRunData = { + created_at?: number | null + elapsed_time?: number | null + error?: string | null + finished_at?: number | null + id: string + outputs?: { + [key: string]: unknown + } + status: string + total_steps?: number | null + total_tokens?: number | null + workflow_id: string +} + +export type WorkspaceDetailResponse = { + created_at?: string | null + current: boolean + id: string + name: string + role: string + status: string +} + +export type WorkspaceListResponse = { + workspaces: Array<WorkspaceSummaryResponse> +} + +export type WorkspacePayload = { + id: string + name: string + role: string +} + +export type WorkspaceSummaryResponse = { + current: boolean + id: string + name: string + role: string + status: string +} + +export type GetHealthData = { + body?: never + path?: never + query?: never + url: '/_health' +} + +export type GetHealthResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses] + +export type GetVersionData = { + body?: never + path?: never + query?: never + url: '/_version' +} + +export type GetVersionResponses = { + 200: ServerVersionResponse +} + +export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses] + +export type GetAccountData = { + body?: never + path?: never + query?: never + url: '/account' +} + +export type GetAccountResponses = { + 200: AccountResponse +} + +export type GetAccountResponse = GetAccountResponses[keyof GetAccountResponses] + +export type GetAccountSessionsData = { + body?: never + path?: never + query?: never + url: '/account/sessions' +} + +export type GetAccountSessionsResponses = { + 200: SessionListResponse +} + +export type GetAccountSessionsResponse + = GetAccountSessionsResponses[keyof GetAccountSessionsResponses] + +export type DeleteAccountSessionsSelfData = { + body?: never + path?: never + query?: never + url: '/account/sessions/self' +} + +export type DeleteAccountSessionsSelfResponses = { + 200: RevokeResponse +} + +export type DeleteAccountSessionsSelfResponse + = DeleteAccountSessionsSelfResponses[keyof DeleteAccountSessionsSelfResponses] + +export type DeleteAccountSessionsBySessionIdData = { + body?: never + path: { + session_id: string + } + query?: never + url: '/account/sessions/{session_id}' +} + +export type DeleteAccountSessionsBySessionIdResponses = { + 200: RevokeResponse +} + +export type DeleteAccountSessionsBySessionIdResponse + = DeleteAccountSessionsBySessionIdResponses[keyof DeleteAccountSessionsBySessionIdResponses] + +export type GetAppsData = { + body?: never + path?: never + query: { + limit?: number + mode?: string + name?: string + page?: number + tag?: string + workspace_id: string + } + url: '/apps' +} + +export type GetAppsResponses = { + 200: AppListResponse +} + +export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses] + +export type GetAppsByAppIdDescribeData = { + body?: never + path: { + app_id: string + } + query?: { + fields?: Array<string> + workspace_id?: string + } + url: '/apps/{app_id}/describe' +} + +export type GetAppsByAppIdDescribeResponses = { + 200: AppDescribeResponse +} + +export type GetAppsByAppIdDescribeResponse + = GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses] + +export type PostAppsByAppIdFilesUploadData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/files/upload' +} + +export type PostAppsByAppIdFilesUploadErrors = { + 400: { + [key: string]: unknown + } + 401: { + [key: string]: unknown + } + 413: { + [key: string]: unknown + } + 415: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdFilesUploadError + = PostAppsByAppIdFilesUploadErrors[keyof PostAppsByAppIdFilesUploadErrors] + +export type PostAppsByAppIdFilesUploadResponses = { + 201: FileResponse +} + +export type PostAppsByAppIdFilesUploadResponse + = PostAppsByAppIdFilesUploadResponses[keyof PostAppsByAppIdFilesUploadResponses] + +export type GetAppsByAppIdFormHumanInputByFormTokenData = { + body?: never + path: { + app_id: string + form_token: string + } + query?: never + url: '/apps/{app_id}/form/human_input/{form_token}' +} + +export type GetAppsByAppIdFormHumanInputByFormTokenResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdFormHumanInputByFormTokenResponse + = GetAppsByAppIdFormHumanInputByFormTokenResponses[keyof GetAppsByAppIdFormHumanInputByFormTokenResponses] + +export type PostAppsByAppIdFormHumanInputByFormTokenData = { + body: HumanInputFormSubmitPayload + path: { + app_id: string + form_token: string + } + query?: never + url: '/apps/{app_id}/form/human_input/{form_token}' +} + +export type PostAppsByAppIdFormHumanInputByFormTokenResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdFormHumanInputByFormTokenResponse + = PostAppsByAppIdFormHumanInputByFormTokenResponses[keyof PostAppsByAppIdFormHumanInputByFormTokenResponses] + +export type PostAppsByAppIdRunData = { + body: AppRunRequest + path: { + app_id: string + } + query?: never + url: '/apps/{app_id}/run' +} + +export type PostAppsByAppIdRunResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdRunResponse + = PostAppsByAppIdRunResponses[keyof PostAppsByAppIdRunResponses] + +export type GetAppsByAppIdTasksByTaskIdEventsData = { + body?: never + path: { + app_id: string + task_id: string + } + query?: never + url: '/apps/{app_id}/tasks/{task_id}/events' +} + +export type GetAppsByAppIdTasksByTaskIdEventsResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetAppsByAppIdTasksByTaskIdEventsResponse + = GetAppsByAppIdTasksByTaskIdEventsResponses[keyof GetAppsByAppIdTasksByTaskIdEventsResponses] + +export type PostAppsByAppIdTasksByTaskIdStopData = { + body?: never + path: { + app_id: string + task_id: string + } + query?: never + url: '/apps/{app_id}/tasks/{task_id}/stop' +} + +export type PostAppsByAppIdTasksByTaskIdStopResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostAppsByAppIdTasksByTaskIdStopResponse + = PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses] + +export type PostOauthDeviceApproveData = { + body: DeviceMutateRequest + path?: never + query?: never + url: '/oauth/device/approve' +} + +export type PostOauthDeviceApproveResponses = { + 200: DeviceMutateResponse +} + +export type PostOauthDeviceApproveResponse + = PostOauthDeviceApproveResponses[keyof PostOauthDeviceApproveResponses] + +export type PostOauthDeviceCodeData = { + body: DeviceCodeRequest + path?: never + query?: never + url: '/oauth/device/code' +} + +export type PostOauthDeviceCodeResponses = { + 200: DeviceCodeResponse +} + +export type PostOauthDeviceCodeResponse + = PostOauthDeviceCodeResponses[keyof PostOauthDeviceCodeResponses] + +export type PostOauthDeviceDenyData = { + body: DeviceMutateRequest + path?: never + query?: never + url: '/oauth/device/deny' +} + +export type PostOauthDeviceDenyResponses = { + 200: DeviceMutateResponse +} + +export type PostOauthDeviceDenyResponse + = PostOauthDeviceDenyResponses[keyof PostOauthDeviceDenyResponses] + +export type GetOauthDeviceLookupData = { + body?: never + path?: never + query: { + user_code: string + } + url: '/oauth/device/lookup' +} + +export type GetOauthDeviceLookupResponses = { + 200: DeviceLookupResponse +} + +export type GetOauthDeviceLookupResponse + = GetOauthDeviceLookupResponses[keyof GetOauthDeviceLookupResponses] + +export type PostOauthDeviceTokenData = { + body: DevicePollRequest + path?: never + query?: never + url: '/oauth/device/token' +} + +export type PostOauthDeviceTokenResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostOauthDeviceTokenResponse + = PostOauthDeviceTokenResponses[keyof PostOauthDeviceTokenResponses] + +export type GetPermittedExternalAppsData = { + body?: never + path?: never + query?: never + url: '/permitted-external-apps' +} + +export type GetPermittedExternalAppsResponses = { + 200: PermittedExternalAppsListResponse +} + +export type GetPermittedExternalAppsResponse + = GetPermittedExternalAppsResponses[keyof GetPermittedExternalAppsResponses] + +export type GetWorkspacesData = { + body?: never + path?: never + query?: never + url: '/workspaces' +} + +export type GetWorkspacesResponses = { + 200: WorkspaceListResponse +} + +export type GetWorkspacesResponse = GetWorkspacesResponses[keyof GetWorkspacesResponses] + +export type GetWorkspacesByWorkspaceIdData = { + body?: never + path: { + workspace_id: string + } + query?: never + url: '/workspaces/{workspace_id}' +} + +export type GetWorkspacesByWorkspaceIdResponses = { + 200: WorkspaceDetailResponse +} + +export type GetWorkspacesByWorkspaceIdResponse + = GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses] diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts new file mode 100644 index 0000000000..6f76b2a6d7 --- /dev/null +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -0,0 +1,548 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * AccountPayload + */ +export const zAccountPayload = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * AppDescribeQuery + * + * `?fields=` allow-list for GET /apps/<id>/describe. + * + * Empty / omitted → all blocks. Unknown member → ValidationError → 422. + */ +export const zAppDescribeQuery = z.object({ + fields: z.array(z.string()).nullish(), + workspace_id: z.string().nullish(), +}) + +/** + * AppMode + */ +export const zAppMode = z.enum([ + 'advanced-chat', + 'agent-chat', + 'channel', + 'chat', + 'completion', + 'rag-pipeline', + 'workflow', +]) + +/** + * AppListQuery + * + * mode is a closed enum. + */ +export const zAppListQuery = z.object({ + limit: z.int().gte(1).lte(200).optional().default(20), + mode: zAppMode.optional(), + name: z.string().max(200).nullish(), + page: z.int().gte(1).optional().default(1), + tag: z.string().max(100).nullish(), + workspace_id: z.string(), +}) + +/** + * AppRunRequest + */ +export const zAppRunRequest = z.object({ + auto_generate_name: z.boolean().optional().default(true), + conversation_id: z.string().nullish(), + files: z.array(z.record(z.string(), z.unknown())).nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string().nullish(), + workflow_id: z.string().nullish(), + workspace_id: z.string().nullish(), +}) + +/** + * DeviceCodeRequest + */ +export const zDeviceCodeRequest = z.object({ + client_id: z.string(), + device_label: z.string(), +}) + +/** + * DeviceCodeResponse + */ +export const zDeviceCodeResponse = z.object({ + device_code: z.string(), + expires_in: z.int(), + interval: z.int(), + user_code: z.string(), + verification_uri: z.string(), +}) + +/** + * DeviceLookupQuery + */ +export const zDeviceLookupQuery = z.object({ + user_code: z.string(), +}) + +/** + * DeviceLookupResponse + */ +export const zDeviceLookupResponse = z.object({ + client_id: z.string().nullish(), + expires_in_remaining: z.int().optional().default(0), + valid: z.boolean(), +}) + +/** + * DeviceMutateRequest + */ +export const zDeviceMutateRequest = z.object({ + user_code: z.string(), +}) + +/** + * DeviceMutateResponse + */ +export const zDeviceMutateResponse = z.object({ + status: z.string(), +}) + +/** + * DevicePollRequest + */ +export const zDevicePollRequest = z.object({ + client_id: z.string(), + device_code: z.string(), +}) + +/** + * FileResponse + */ +export const zFileResponse = z.object({ + conversation_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + extension: z.string().nullish(), + file_key: z.string().nullish(), + id: z.string(), + mime_type: z.string().nullish(), + name: z.string(), + original_url: z.string().nullish(), + preview_url: z.string().nullish(), + size: z.int(), + source_url: z.string().nullish(), + tenant_id: z.string().nullish(), + user_id: z.string().nullish(), +}) + +export const zJsonValue = z.unknown() + +/** + * HumanInputFormSubmitPayload + */ +export const zHumanInputFormSubmitPayload = z.object({ + action: z.string(), + inputs: z.record(z.string(), zJsonValue), +}) + +/** + * PermittedExternalAppsListQuery + * + * Strict (extra='forbid'). + */ +export const zPermittedExternalAppsListQuery = z.object({ + limit: z.int().gte(1).lte(200).optional().default(20), + mode: zAppMode.optional(), + name: z.string().max(200).nullish(), + page: z.int().gte(1).optional().default(1), +}) + +/** + * RevokeResponse + */ +export const zRevokeResponse = z.object({ + status: z.string(), +}) + +/** + * ServerVersionResponse + * + * Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. + */ +export const zServerVersionResponse = z.object({ + edition: z.enum(['CLOUD', 'SELF_HOSTED']), + version: z.string(), +}) + +/** + * SessionRow + */ +export const zSessionRow = z.object({ + client_id: z.string(), + created_at: z.string().nullish(), + device_label: z.string(), + expires_at: z.string().nullish(), + id: z.string(), + last_used_at: z.string().nullish(), + prefix: z.string(), +}) + +/** + * SessionListResponse + */ +export const zSessionListResponse = z.object({ + data: z.array(zSessionRow), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * TagItem + */ +export const zTagItem = z.object({ + name: z.string(), +}) + +/** + * AppDescribeInfo + */ +export const zAppDescribeInfo = z.object({ + author: z.string().nullish(), + description: z.string().nullish(), + id: z.string(), + is_agent: z.boolean().optional().default(false), + mode: z.string(), + name: z.string(), + service_api_enabled: z.boolean(), + tags: z.array(zTagItem).optional().default([]), + updated_at: z.string().nullish(), +}) + +/** + * AppDescribeResponse + */ +export const zAppDescribeResponse = z.object({ + info: zAppDescribeInfo.optional(), + input_schema: z.record(z.string(), z.unknown()).nullish(), + parameters: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * AppInfoResponse + */ +export const zAppInfoResponse = z.object({ + author: z.string().nullish(), + description: z.string().nullish(), + id: z.string(), + mode: z.string(), + name: z.string(), + tags: z.array(zTagItem).optional().default([]), +}) + +/** + * AppListRow + */ +export const zAppListRow = z.object({ + created_by_name: z.string().nullish(), + description: z.string().nullish(), + id: z.string(), + mode: zAppMode, + name: z.string(), + tags: z.array(zTagItem).optional().default([]), + updated_at: z.string().nullish(), + workspace_id: z.string().nullish(), + workspace_name: z.string().nullish(), +}) + +/** + * AppListResponse + */ +export const zAppListResponse = z.object({ + data: z.array(zAppListRow), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * PermittedExternalAppsListResponse + */ +export const zPermittedExternalAppsListResponse = z.object({ + data: z.array(zAppListRow), + has_more: z.boolean(), + limit: z.int(), + page: z.int(), + total: z.int(), +}) + +/** + * UsageInfo + */ +export const zUsageInfo = z.object({ + completion_tokens: z.int().optional().default(0), + prompt_tokens: z.int().optional().default(0), + total_tokens: z.int().optional().default(0), +}) + +/** + * MessageMetadata + */ +export const zMessageMetadata = z.object({ + retriever_resources: z.array(z.record(z.string(), z.unknown())).optional().default([]), + usage: zUsageInfo.optional(), +}) + +/** + * WorkflowRunData + */ +export const zWorkflowRunData = z.object({ + created_at: z.int().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + outputs: z.record(z.string(), z.unknown()).optional(), + status: z.string(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + workflow_id: z.string(), +}) + +/** + * WorkspaceDetailResponse + */ +export const zWorkspaceDetailResponse = z.object({ + created_at: z.string().nullish(), + current: z.boolean(), + id: z.string(), + name: z.string(), + role: z.string(), + status: z.string(), +}) + +/** + * WorkspacePayload + */ +export const zWorkspacePayload = z.object({ + id: z.string(), + name: z.string(), + role: z.string(), +}) + +/** + * AccountResponse + */ +export const zAccountResponse = z.object({ + account: zAccountPayload.optional(), + default_workspace_id: z.string().nullish(), + subject_email: z.string().nullish(), + subject_issuer: z.string().nullish(), + subject_type: z.string(), + workspaces: z.array(zWorkspacePayload).optional().default([]), +}) + +/** + * WorkspaceSummaryResponse + */ +export const zWorkspaceSummaryResponse = z.object({ + current: z.boolean(), + id: z.string(), + name: z.string(), + role: z.string(), + status: z.string(), +}) + +/** + * WorkspaceListResponse + */ +export const zWorkspaceListResponse = z.object({ + workspaces: z.array(zWorkspaceSummaryResponse), +}) + +/** + * Success + */ +export const zGetHealthResponse = z.record(z.string(), z.unknown()) + +/** + * Server version + */ +export const zGetVersionResponse = zServerVersionResponse + +/** + * Account info + */ +export const zGetAccountResponse = zAccountResponse + +/** + * Session list + */ +export const zGetAccountSessionsResponse = zSessionListResponse + +/** + * Session revoked + */ +export const zDeleteAccountSessionsSelfResponse = zRevokeResponse + +export const zDeleteAccountSessionsBySessionIdPath = z.object({ + session_id: z.string(), +}) + +/** + * Session revoked + */ +export const zDeleteAccountSessionsBySessionIdResponse = zRevokeResponse + +export const zGetAppsQuery = z.object({ + limit: z.int().gte(1).lte(200).optional().default(20), + mode: z.string().optional(), + name: z.string().max(200).optional(), + page: z.int().gte(1).optional().default(1), + tag: z.string().max(100).optional(), + workspace_id: z.string(), +}) + +/** + * App list + */ +export const zGetAppsResponse = zAppListResponse + +export const zGetAppsByAppIdDescribePath = z.object({ + app_id: z.string(), +}) + +export const zGetAppsByAppIdDescribeQuery = z.object({ + fields: z.array(z.string()).optional(), + workspace_id: z.string().optional(), +}) + +/** + * App description + */ +export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse + +export const zPostAppsByAppIdFilesUploadPath = z.object({ + app_id: z.string(), +}) + +/** + * File uploaded successfully + */ +export const zPostAppsByAppIdFilesUploadResponse = zFileResponse + +export const zGetAppsByAppIdFormHumanInputByFormTokenPath = z.object({ + app_id: z.string(), + form_token: z.string(), +}) + +/** + * Form definition + */ +export const zGetAppsByAppIdFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdFormHumanInputByFormTokenBody = zHumanInputFormSubmitPayload + +export const zPostAppsByAppIdFormHumanInputByFormTokenPath = z.object({ + app_id: z.string(), + form_token: z.string(), +}) + +/** + * Form submitted + */ +export const zPostAppsByAppIdFormHumanInputByFormTokenResponse = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdRunBody = zAppRunRequest + +export const zPostAppsByAppIdRunPath = z.object({ + app_id: z.string(), +}) + +/** + * Run result (SSE stream) + */ +export const zPostAppsByAppIdRunResponse = z.record(z.string(), z.unknown()) + +export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({ + app_id: z.string(), + task_id: z.string(), +}) + +/** + * SSE event stream + */ +export const zGetAppsByAppIdTasksByTaskIdEventsResponse = z.record(z.string(), z.unknown()) + +export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({ + app_id: z.string(), + task_id: z.string(), +}) + +/** + * Task stopped + */ +export const zPostAppsByAppIdTasksByTaskIdStopResponse = z.record(z.string(), z.unknown()) + +export const zPostOauthDeviceApproveBody = zDeviceMutateRequest + +/** + * Approved + */ +export const zPostOauthDeviceApproveResponse = zDeviceMutateResponse + +export const zPostOauthDeviceCodeBody = zDeviceCodeRequest + +/** + * Device code created + */ +export const zPostOauthDeviceCodeResponse = zDeviceCodeResponse + +export const zPostOauthDeviceDenyBody = zDeviceMutateRequest + +/** + * Denied + */ +export const zPostOauthDeviceDenyResponse = zDeviceMutateResponse + +export const zGetOauthDeviceLookupQuery = z.object({ + user_code: z.string(), +}) + +/** + * Device lookup result + */ +export const zGetOauthDeviceLookupResponse = zDeviceLookupResponse + +export const zPostOauthDeviceTokenBody = zDevicePollRequest + +/** + * Success + */ +export const zPostOauthDeviceTokenResponse = z.record(z.string(), z.unknown()) + +/** + * Permitted external apps list + */ +export const zGetPermittedExternalAppsResponse = zPermittedExternalAppsListResponse + +/** + * Workspace list + */ +export const zGetWorkspacesResponse = zWorkspaceListResponse + +export const zGetWorkspacesByWorkspaceIdPath = z.object({ + workspace_id: z.string(), +}) + +/** + * Workspace detail + */ +export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index ccf838880a..58ff98351e 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -100,6 +100,7 @@ const apiSpecs: ApiSpec[] = [ { filename: 'console-swagger.json', name: 'console' }, { filename: 'web-swagger.json', name: 'web' }, { filename: 'service-swagger.json', name: 'service' }, + { filename: 'openapi-swagger.json', name: 'openapi' }, ] const inaccurateGeneratedContractDescription = 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.' diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index c517153bf6..f85210e8f6 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -172,6 +172,7 @@ "@types/react-dom": "catalog:", "@typescript/native-preview": "catalog:", "@vitejs/plugin-react": "catalog:", + "@vitest/browser": "catalog:", "@vitest/coverage-v8": "catalog:", "class-variance-authority": "catalog:", "playwright": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c28d185d98..3dc99af4a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ catalogs: '@monaco-editor/react': specifier: 4.7.0 version: 4.7.0 + '@napi-rs/keyring': + specifier: 1.1.6 + version: 1.1.6 '@next/eslint-plugin-next': specifier: 16.2.6 version: 16.2.6 @@ -240,6 +243,9 @@ catalogs: '@vitejs/plugin-rsc': specifier: 0.5.26 version: 0.5.26 + '@vitest/browser': + specifier: 4.1.7 + version: 4.1.7 '@vitest/coverage-v8': specifier: 4.1.7 version: 4.1.7 @@ -261,6 +267,9 @@ catalogs: class-variance-authority: specifier: 0.7.1 version: 0.7.1 + cli-table3: + specifier: 0.6.5 + version: 0.6.5 client-only: specifier: 0.0.1 version: 0.0.1 @@ -339,6 +348,9 @@ catalogs: eslint-plugin-storybook: specifier: 10.4.1 version: 10.4.1 + eventsource-parser: + specifier: 3.0.5 + version: 3.0.5 fast-deep-equal: specifier: 3.1.3 version: 3.1.3 @@ -426,6 +438,15 @@ catalogs: nuqs: specifier: 2.8.9 version: 2.8.9 + open: + specifier: 10.1.0 + version: 10.1.0 + ora: + specifier: 8.1.0 + version: 8.1.0 + picocolors: + specifier: 1.1.0 + version: 1.1.0 pinyin-pro: specifier: 3.28.1 version: 3.28.1 @@ -586,7 +607,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) concurrently: specifier: 'catalog:' version: 9.2.1 @@ -607,7 +628,77 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + + cli: + dependencies: + '@dify/contracts': + specifier: workspace:* + version: link:../packages/contracts + '@napi-rs/keyring': + specifier: 'catalog:' + version: 1.1.6 + cli-table3: + specifier: 'catalog:' + version: 0.6.5 + eventsource-parser: + specifier: 'catalog:' + version: 3.0.5 + js-yaml: + specifier: 'catalog:' + version: 4.1.1 + ky: + specifier: 'catalog:' + version: 2.0.2 + open: + specifier: 'catalog:' + version: 10.1.0 + ora: + specifier: 'catalog:' + version: 8.1.0 + picocolors: + specifier: 'catalog:' + version: 1.1.0 + std-semver: + specifier: 'catalog:' + version: 1.0.8 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@dify/tsconfig': + specifier: workspace:* + version: link:../packages/tsconfig + '@hono/node-server': + specifier: 'catalog:' + version: 2.0.3(hono@4.12.22) + '@types/js-yaml': + specifier: 'catalog:' + version: 4.0.9 + '@types/node': + specifier: 'catalog:' + version: 25.9.1 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22) + eslint: + specifier: 'catalog:' + version: 10.4.0(jiti@2.7.0) + hono: + specifier: 'catalog:' + version: 4.12.22 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.22 + version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + vite-plus: + specifier: 'catalog:' + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + vitest: + specifier: npm:@voidzero-dev/vite-plus-test@0.1.22 + version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' e2e: devDependencies: @@ -637,7 +728,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) packages/contracts: dependencies: @@ -674,7 +765,7 @@ importers: version: 6.0.3 vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) packages/dev-proxy: dependencies: @@ -705,10 +796,10 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@0.1.22 - version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' packages/dify-ui: dependencies: @@ -724,7 +815,7 @@ importers: version: 1.5.0(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@dify/tsconfig': specifier: workspace:* version: link:../tsconfig @@ -736,16 +827,16 @@ importers: version: 1.2.10 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/react-vite': specifier: 'catalog:' - version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@tailwindcss/vite': specifier: 'catalog:' version: 4.3.0(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) @@ -764,9 +855,12 @@ importers: '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.2(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/browser': + specifier: 'catalog:' + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22) class-variance-authority: specifier: 'catalog:' version: 0.7.1 @@ -781,7 +875,7 @@ importers: version: 19.2.6(react@19.2.6) storybook: specifier: 'catalog:' - version: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + version: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.0 @@ -793,10 +887,10 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) vitest-browser-react: specifier: 'catalog:' - version: 2.2.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 2.2.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) packages/iconify-collections: devDependencies: @@ -827,7 +921,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) packages/tsconfig: {} @@ -853,7 +947,7 @@ importers: version: 7.0.0-dev.20260523.1 '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22) eslint: specifier: 'catalog:' version: 10.4.0(jiti@2.7.0) @@ -865,10 +959,10 @@ importers: version: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@0.1.22 - version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' web: dependencies: @@ -1181,10 +1275,10 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 'catalog:' - version: 9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) '@chromatic-com/storybook': specifier: 'catalog:' - version: 5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@dify/contracts': specifier: workspace:* version: link:../packages/contracts @@ -1232,22 +1326,22 @@ importers: version: 4.2.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-links': specifier: 'catalog:' - version: 10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-onboarding': specifier: 'catalog:' - version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/addon-themes': specifier: 'catalog:' - version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + version: 10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/nextjs-vite': specifier: 'catalog:' - version: 10.4.1(35df1fbb9962d4f471e6edf015518218) + version: 10.4.1(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@storybook/react': specifier: 'catalog:' - version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) '@tailwindcss/postcss': specifier: 'catalog:' version: 4.3.0 @@ -1325,7 +1419,7 @@ importers: version: 0.5.26(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(react-dom@19.2.6(react@19.2.6))(react-server-dom-webpack@19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) '@vitest/coverage-v8': specifier: 'catalog:' - version: 4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22) agentation: specifier: 'catalog:' version: 3.0.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1358,7 +1452,7 @@ importers: version: 4.0.3(eslint@10.4.0(jiti@2.7.0)) eslint-plugin-storybook: specifier: 'catalog:' - version: 10.4.1(eslint@10.4.0(jiti@2.7.0))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + version: 10.4.1(eslint@10.4.0(jiti@2.7.0))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) happy-dom: specifier: 'catalog:' version: 20.9.0 @@ -1373,7 +1467,7 @@ importers: version: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) storybook: specifier: 'catalog:' - version: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + version: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) tailwindcss: specifier: 'catalog:' version: 4.3.0 @@ -1397,13 +1491,13 @@ importers: version: 12.0.0-beta.2(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) vite-plus: specifier: 'catalog:' - version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + version: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) vitest: specifier: npm:@voidzero-dev/vite-plus-test@0.1.22 - version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + version: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + version: 1.1.4(@voidzero-dev/vite-plus-test@0.1.22) packages: @@ -1666,6 +1760,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -2651,6 +2748,87 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/keyring-darwin-arm64@1.1.6': + resolution: {integrity: sha512-8N+qvM+O6OSU59BTgDP/PvqYhoqfOcD2HGy1NgRFo1B0DRmkTp4U/DGZrV4Pk/nOP6Uf0PLqznfx3a/M8O5sjQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.1.6': + resolution: {integrity: sha512-r3Jgc5/ubfaao6Lmk/USA13IwU/GEVLP8NDfg5gYXjPVllU6bWnAaEDHVg7q4vl51kViwj9ELo6XTmOeJFut6A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.1.6': + resolution: {integrity: sha512-ayG396jZAt7j820gsEyW/LJKn+rf9KtgSPq1NKpvu84Y5GXopoFLyjMIP7wYZ1RLBL6SGKy27/f8S4f6YZ4DuA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.1.6': + resolution: {integrity: sha512-8nXavgxcaUTUxyFHR+PEQF7eC8rITlYZNUmlf5amTb36y5bkNKrc3QLvCxjtbFSR/+KYzMi3vydoqNmFpF616w==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.1.6': + resolution: {integrity: sha512-qsI2NTAxGD3mBhZvdyYGL+N0n1D/NAjV0zCpTsFKKSzdpIrQJ0nM5Y0HxlLi6TsHm61dMyXHkdHb0ut8AzTcGA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.1.6': + resolution: {integrity: sha512-SB/2A4LtL+SrS2aZXl3rWBtyCVB2aG2zAU56kOGFDGwRZM2tqaITuQoM1QLOAMwu0eksN/Xedy95Yn2rkRH0nQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.1.6': + resolution: {integrity: sha512-BcjXf33T2CoVgS87SvZ62Y6xxkbenNIeldy0r8O5nz6zFgN+wYB0scz5ulvowEYBQnhi4fmbxfneeqM/0HUOeA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.1.6': + resolution: {integrity: sha512-eK0OxCBI6Wl8rFHYynrtEID6pxOwhPfnpIIpul7UPeqCCMJSyZpFN4lFP3oZ4vqX/6FnWjwMrR7IGbPgivdMjA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.1.6': + resolution: {integrity: sha512-Qb3NP98KFq4jXmk9PUQlcYrHjbzsBTtG+OOxX4YxUNKTGuUaIOGP79lB0w7jhns2oHdq8DwkW2ugzlmGSUaRSw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.1.6': + resolution: {integrity: sha512-e794gO2CLD0P7JN2DVPT5CC60k3WmNWTWU5BVoQM8Hj0NYebx7j6LyxMIpdb2cztOHHiv7iltEHekgutf0TMlA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.1.6': + resolution: {integrity: sha512-SUPafl6vKRMQBKZoSwIeBFZ+c7AGEKUy6mpAD9fVHDKHOBWP3VpHKda4YIlgGtQd3SxH0bjfqJ078Z5SYsDYZQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.1.6': + resolution: {integrity: sha512-FkNhM1x5ijFzGSrRcshRxUxQSrrjxl4wCmvRcXnimWreOHyzNotT+/1EZtSfM/k8yhdK0HEkkVIMQl0UqfioRw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.1.6': + resolution: {integrity: sha512-e6xoYELSMyaxcXv4MmEHhf0oOGsMnfWMmeu84CD91ICMgMH1I1vrLSMFpiPEQz03xD+pNQgAkQ7DwwBDozCuvw==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -4613,9 +4791,6 @@ packages: '@types/negotiator@0.6.4': resolution: {integrity: sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==} - '@types/node@25.9.0': - resolution: {integrity: sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==} - '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -4827,10 +5002,14 @@ packages: react-server-dom-webpack: optional: true + '@vitest/browser@4.1.7': + resolution: {integrity: sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ==} + '@vitest/coverage-v8@4.1.7': resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} peerDependencies: '@vitest/browser': 4.1.7 + vitest: 4.1.7 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4851,6 +5030,17 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} @@ -4860,6 +5050,9 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -5279,6 +5472,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} @@ -5351,6 +5548,14 @@ packages: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cli-table3@0.6.5: resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} engines: {node: 10.* || >= 12.*} @@ -6232,6 +6437,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventsource-parser@3.0.5: + resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==} + engines: {node: '>=20.0.0'} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -6653,6 +6862,10 @@ packages: resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} engines: {node: '>=10'} + 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'} @@ -6669,6 +6882,14 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -6926,6 +7147,10 @@ packages: resolution: {integrity: sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==} deprecated: Bad release. Please use lodash@4.17.21 instead. + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + logs-sdk@0.0.6: resolution: {integrity: sha512-G4M1C9aLLBOIWpmw/Lqk4zrap/T2IJsoUOuUDjRcVSLy6lHQqxr3wCqIT1FvvpYTUYpEwvu4utsMY42jTNvx8Q==} @@ -7191,6 +7416,10 @@ 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'} @@ -7361,12 +7590,20 @@ 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==} oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@10.1.0: + resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} + engines: {node: '>=18'} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -7382,6 +7619,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.1.0: + resolution: {integrity: sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==} + engines: {node: '>=18'} + oxc-parser@0.126.0: resolution: {integrity: sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7511,6 +7752,9 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7938,6 +8182,10 @@ 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'} @@ -8031,6 +8279,10 @@ packages: resolution: {integrity: sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q==} engines: {node: '>=20'} + 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==} @@ -8116,6 +8368,10 @@ packages: resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} engines: {node: '>=20.19.0'} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + storybook@10.4.1: resolution: {integrity: sha512-V1Zd2e+gBFufqAQVZ1JR8KLqALsEZ3JYSBnWwQbKa6zCfWWanR6AFMyuOkLt2gZOgGp3h2Riuz88pGNVTQSG0A==} hasBin: true @@ -9007,7 +9263,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.0.0-alpha.40': dependencies: - postcss: 8.5.14 + postcss: 8.5.15 '@amplitude/rrweb-types@2.0.0-alpha.40': {} @@ -9052,7 +9308,7 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': + '@antfu/eslint-config@9.0.0(@eslint-react/eslint-plugin@5.8.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@next/eslint-plugin-next@16.2.6)(@types/node@25.9.1)(@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3))(@typescript-eslint/utils@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint-plugin-react-refresh@0.5.2(eslint@10.4.0(jiti@2.7.0)))(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(oxlint@1.63.0(oxlint-tsgolint@0.22.1))(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.4.0 @@ -9062,7 +9318,7 @@ snapshots: '@stylistic/eslint-plugin': 5.10.0(eslint@10.4.0(jiti@2.7.0)) '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) '@typescript-eslint/parser': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.1)(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + '@vitest/eslint-plugin': 1.6.17(@types/node@25.9.1)(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) ansis: 4.3.0 cac: 7.0.0 eslint: 10.4.0(jiti@2.7.0) @@ -9268,16 +9524,18 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.2': {} '@chevrotain/types@11.1.2': {} - '@chromatic-com/storybook@5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@chromatic-com/storybook@5.2.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 16.10.0 jsonfile: 6.2.0 - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -10343,6 +10601,57 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + '@napi-rs/keyring-darwin-arm64@1.1.6': + optional: true + + '@napi-rs/keyring-darwin-x64@1.1.6': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.1.6': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.1.6': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.1.6': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.1.6': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.1.6': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.1.6': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.1.6': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.1.6': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.1.6': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.1.6': + optional: true + + '@napi-rs/keyring@1.1.6': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.1.6 + '@napi-rs/keyring-darwin-x64': 1.1.6 + '@napi-rs/keyring-freebsd-x64': 1.1.6 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.1.6 + '@napi-rs/keyring-linux-arm64-gnu': 1.1.6 + '@napi-rs/keyring-linux-arm64-musl': 1.1.6 + '@napi-rs/keyring-linux-riscv64-gnu': 1.1.6 + '@napi-rs/keyring-linux-x64-gnu': 1.1.6 + '@napi-rs/keyring-linux-x64-musl': 1.1.6 + '@napi-rs/keyring-win32-arm64-msvc': 1.1.6 + '@napi-rs/keyring-win32-ia32-msvc': 1.1.6 + '@napi-rs/keyring-win32-x64-msvc': 1.1.6 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -11270,15 +11579,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-docs@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.15)(react@19.2.6) - '@storybook/csf-plugin': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/csf-plugin': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) '@storybook/icons': 2.0.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@storybook/react-dom-shim': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 optionalDependencies: '@types/react': 19.2.15 @@ -11289,27 +11598,27 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-links@10.4.1(@types/react@19.2.15)(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.15 react: 19.2.6 - '@storybook/addon-onboarding@10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-onboarding@10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) - '@storybook/addon-themes@10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/addon-themes@10.4.1(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/builder-vite@10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - '@storybook/csf-plugin': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + '@storybook/csf-plugin': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: @@ -11317,9 +11626,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/csf-plugin@10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 @@ -11332,18 +11641,18 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@storybook/nextjs-vite@10.4.1(35df1fbb9962d4f471e6edf015518218)': + '@storybook/nextjs-vite@10.4.1(@babel/core@7.29.0)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: - '@storybook/builder-vite': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) - '@storybook/react': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) - '@storybook/react-vite': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + '@storybook/builder-vite': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + '@storybook/react-vite': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.6) vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' - vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + vite-plugin-storybook-nextjs: 3.2.4(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) optionalDependencies: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) @@ -11356,28 +11665,28 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': + '@storybook/react-dom-shim@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))': dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) - '@storybook/react-vite@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': + '@storybook/react-vite@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) '@rollup/pluginutils': 5.3.0 - '@storybook/builder-vite': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) - '@storybook/react': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) + '@storybook/builder-vite': 10.4.1(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.6 react-docgen: 8.0.3 react-dom: 19.2.6(react@19.2.6) resolve: 1.22.11 - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) tsconfig-paths: 4.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' transitivePeerDependencies: @@ -11389,15 +11698,15 @@ snapshots: - typescript - webpack - '@storybook/react@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': + '@storybook/react@10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) + '@storybook/react-dom-shim': 10.4.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))) react: 19.2.6 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@6.0.3) react-dom: 19.2.6(react@19.2.6) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) @@ -11948,10 +12257,6 @@ snapshots: '@types/negotiator@0.6.4': {} - '@types/node@25.9.0': - dependencies: - undici-types: 7.24.6 - '@types/node@25.9.1': dependencies: undici-types: 7.24.6 @@ -11960,7 +12265,7 @@ snapshots: '@types/papaparse@5.5.2': dependencies: - '@types/node': 25.9.0 + '@types/node': 25.9.1 '@types/qs@6.15.1': {} @@ -11986,11 +12291,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.0 + '@types/node': 25.9.1 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.9.0 + '@types/node': 25.9.1 optional: true '@types/zen-observable@0.8.3': {} @@ -12183,19 +12488,17 @@ snapshots: optionalDependencies: react-server-dom-webpack: 19.2.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': + '@vitest/browser@4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': dependencies: - '@bcoe/v8-coverage': 1.0.2 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.7(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/utils': 4.1.7 - ast-v8-to-istanbul: 1.0.0 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 4.0.0 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + ws: 8.21.0 transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -12205,6 +12508,7 @@ snapshots: - '@types/node' - '@vitejs/devtools' - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' - '@vitest/ui' - bufferutil - esbuild @@ -12212,6 +12516,7 @@ snapshots: - jiti - jsdom - less + - msw - publint - sass - sass-embedded @@ -12226,12 +12531,28 @@ snapshots: - vite - yaml - '@vitest/eslint-plugin@1.6.17(@types/node@25.9.1)(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': + '@vitest/coverage-v8@4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.7 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + optionalDependencies: + '@vitest/browser': 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + + '@vitest/eslint-plugin@1.6.17(@types/node@25.9.1)(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(eslint@10.4.0(jiti@2.7.0))(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@typescript-eslint/scope-manager': 8.59.4 '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.4.0(jiti@2.7.0) - vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: '@typescript-eslint/eslint-plugin': 8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) typescript: 6.0.3 @@ -12274,6 +12595,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@4.1.7(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -12286,6 +12615,8 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/spy@4.1.7': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -12303,7 +12634,7 @@ snapshots: '@oxc-project/runtime': 0.129.0 '@oxc-project/types': 0.129.0 lightningcss: 1.32.0 - postcss: 8.5.14 + postcss: 8.5.15 optionalDependencies: '@types/node': 25.9.1 esbuild: 0.27.2 @@ -12331,7 +12662,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.1.22': optional: true - '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': + '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 @@ -12349,7 +12680,7 @@ snapshots: ws: 8.21.0 optionalDependencies: '@types/node': 25.9.1 - '@vitest/coverage-v8': 4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + '@vitest/coverage-v8': 4.1.7(@vitest/browser@4.1.7)(@voidzero-dev/vite-plus-test@0.1.22) happy-dom: 20.9.0 transitivePeerDependencies: - '@arethetypeswrong/core' @@ -12637,6 +12968,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + change-case@5.4.4: {} character-entities-html4@2.1.0: {} @@ -12701,6 +13034,12 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + cli-table3@0.6.5: dependencies: string-width: 8.2.1 @@ -13617,11 +13956,11 @@ snapshots: ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 - eslint-plugin-storybook@10.4.1(eslint@10.4.0(jiti@2.7.0))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): + eslint-plugin-storybook@10.4.1(eslint@10.4.0(jiti@2.7.0))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): dependencies: '@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.4.0(jiti@2.7.0) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - typescript @@ -13803,6 +14142,8 @@ snapshots: esutils@2.0.3: {} + eventsource-parser@3.0.5: {} + expand-template@2.0.3: optional: true @@ -13979,7 +14320,7 @@ snapshots: happy-dom@20.9.0: dependencies: - '@types/node': 25.9.0 + '@types/node': 25.9.1 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -14266,6 +14607,8 @@ snapshots: global-dirs: 3.0.1 is-path-inside: 3.0.3 + is-interactive@2.0.0: {} + is-number@7.0.0: {} is-path-inside@3.0.3: {} @@ -14274,6 +14617,10 @@ snapshots: is-stream@2.0.1: {} + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -14485,6 +14832,11 @@ snapshots: lodash@4.18.0: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + logs-sdk@0.0.6: dependencies: magic-string: 0.30.21 @@ -15074,6 +15426,8 @@ snapshots: mime@4.1.0: {} + mimic-function@5.0.1: {} + mimic-response@3.1.0: optional: true @@ -15214,6 +15568,10 @@ 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: @@ -15222,6 +15580,13 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@10.1.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 3.1.1 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -15249,6 +15614,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.1.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 8.2.1 + strip-ansi: 7.2.0 + oxc-parser@0.126.0: dependencies: '@oxc-project/types': 0.126.0 @@ -15501,6 +15878,8 @@ snapshots: perfect-debounce@2.1.0: {} + picocolors@1.1.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -16029,6 +16408,11 @@ 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: {} @@ -16147,6 +16531,8 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + signal-exit@4.1.0: {} + simple-concat@1.0.1: optional: true @@ -16237,7 +16623,9 @@ snapshots: std-semver@1.0.8: {} - storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)): + stdin-discarder@0.2.2: {} + + storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16256,7 +16644,7 @@ snapshots: ws: 8.21.0 optionalDependencies: '@types/react': 19.2.15 - vite-plus: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + vite-plus: 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -16765,14 +17153,14 @@ snapshots: - typescript - utf-8-validate - vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): + vite-plugin-storybook-nextjs@3.2.4(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(storybook@10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)))(typescript@6.0.3): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 next: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) + storybook: 10.4.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@testing-library/dom@10.4.1)(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(typescript@6.0.3) @@ -16780,12 +17168,12 @@ snapshots: - supports-color - typescript - vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0): + vite-plus@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.129.0 '@oxlint/plugins': 1.61.0 '@voidzero-dev/vite-plus-core': 0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) - '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + '@voidzero-dev/vite-plus-test': 0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) oxfmt: 0.48.0 oxlint: 1.63.0(oxlint-tsgolint@0.22.1) oxlint-tsgolint: 0.22.1 @@ -16854,11 +17242,11 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' - vitest-browser-react@2.2.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0): + vitest-browser-react@2.2.0(@types/node@25.9.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0): dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' optionalDependencies: '@types/react': 19.2.15 '@types/react-dom': 19.2.3(@types/react@19.2.15) @@ -16893,11 +17281,11 @@ snapshots: - vite - yaml - vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)): + vitest-canvas-mock@1.1.4(@voidzero-dev/vite-plus-test@0.1.22): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7(@types/node@25.9.1)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' + vitest: '@voidzero-dev/vite-plus-test@0.1.22(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)' void-elements@3.1.0: {} @@ -17064,6 +17452,7 @@ time: '@mdx-js/react@3.1.1': '2025-08-29T18:02:56.462Z' '@mdx-js/rollup@3.1.1': '2025-08-29T18:03:10.680Z' '@monaco-editor/react@4.7.0': '2025-02-13T16:13:41.390Z' + '@napi-rs/keyring@1.1.6': '2024-04-17T14:11:13.910Z' '@next/eslint-plugin-next@16.2.6': '2026-05-07T19:01:24.678Z' '@next/mdx@16.2.6': '2026-05-07T19:01:57.704Z' '@orpc/client@1.14.3': '2026-05-12T06:29:27.973Z' @@ -17115,6 +17504,7 @@ time: '@typescript/native-preview@7.0.0-dev.20260523.1': '2026-05-23T08:03:34.248Z' '@vitejs/plugin-react@6.0.2': '2026-05-14T20:03:24.044Z' '@vitejs/plugin-rsc@0.5.26': '2026-05-07T02:07:38.518Z' + '@vitest/browser@4.1.7': '2026-05-20T07:19:38.359Z' '@vitest/coverage-v8@4.1.7': '2026-05-20T07:19:59.911Z' '@voidzero-dev/vite-plus-core@0.1.22': '2026-05-20T01:38:17.678Z' '@voidzero-dev/vite-plus-test@0.1.22': '2026-05-20T01:38:23.302Z' @@ -17124,6 +17514,7 @@ time: c12@4.0.0-beta.5: '2026-05-06T17:28:34.367Z' 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' client-only@0.0.1: '2022-09-03T01:07:11.981Z' clsx@2.1.1: '2024-04-23T05:26:04.645Z' cmdk@1.1.1: '2025-03-14T19:21:16.194Z' @@ -17150,6 +17541,7 @@ time: eslint-plugin-sonarjs@4.0.3: '2026-04-16T08:09:42.856Z' eslint-plugin-storybook@10.4.1: '2026-05-22T09:44:48.502Z' eslint@10.4.0: '2026-05-15T14:11:13.606Z' + eventsource-parser@3.0.5: '2025-08-18T21:47:18.229Z' fast-deep-equal@3.1.3: '2020-06-08T07:27:28.474Z' fuse.js@7.3.0: '2026-04-04T16:58:52.671Z' happy-dom@20.9.0: '2026-04-13T22:55:15.313Z' @@ -17180,6 +17572,9 @@ time: next-themes@0.4.6: '2025-03-11T21:02:05.882Z' next@16.2.6: '2026-05-07T19:01:54.751Z' nuqs@2.8.9: '2026-02-27T15:51:04.508Z' + open@10.1.0: '2024-03-08T15:46:33.286Z' + ora@8.1.0: '2024-08-25T17:48:11.817Z' + picocolors@1.1.0: '2024-09-02T23:46:31.018Z' pinyin-pro@3.28.1: '2026-04-10T09:18:57.903Z' playwright@1.60.0: '2026-05-11T19:09:33.114Z' postcss@8.5.15: '2026-05-19T09:51:29.843Z' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7080ff0fb9..5795e55a81 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,7 @@ packages: - e2e - sdks/nodejs-client - packages/* + - cli overrides: '@lexical/code': npm:lexical-code-no-prism@0.41.0 canvas: ^3.2.3 @@ -71,6 +72,7 @@ catalog: '@mdx-js/react': 3.1.1 '@mdx-js/rollup': 3.1.1 '@monaco-editor/react': 4.7.0 + '@napi-rs/keyring': 1.1.6 '@next/eslint-plugin-next': 16.2.6 '@next/mdx': 16.2.6 '@orpc/client': 1.14.3 @@ -122,6 +124,7 @@ catalog: '@typescript/native-preview': 7.0.0-dev.20260523.1 '@vitejs/plugin-react': 6.0.2 '@vitejs/plugin-rsc': 0.5.26 + '@vitest/browser': 4.1.7 '@vitest/coverage-v8': 4.1.7 abcjs: 6.6.3 agentation: 3.0.2 @@ -129,6 +132,7 @@ catalog: c12: 4.0.0-beta.5 chokidar: 5.0.0 class-variance-authority: 0.7.1 + cli-table3: 0.6.5 client-only: 0.0.1 clsx: 2.1.1 cmdk: 1.1.1 @@ -155,6 +159,7 @@ catalog: eslint-plugin-react-refresh: 0.5.2 eslint-plugin-sonarjs: 4.0.3 eslint-plugin-storybook: 10.4.1 + eventsource-parser: 3.0.5 fast-deep-equal: 3.1.3 fuse.js: 7.3.0 happy-dom: 20.9.0 @@ -184,6 +189,9 @@ catalog: next: 16.2.6 next-themes: 0.4.6 nuqs: 2.8.9 + open: 10.1.0 + ora: 8.1.0 + picocolors: 1.1.0 pinyin-pro: 3.28.1 playwright: 1.60.0 postcss: 8.5.15 diff --git a/web/app/device/__tests__/page-terminal.spec.tsx b/web/app/device/__tests__/page-terminal.spec.tsx new file mode 100644 index 0000000000..57d749897c --- /dev/null +++ b/web/app/device/__tests__/page-terminal.spec.tsx @@ -0,0 +1,112 @@ +import { useQuery } from '@tanstack/react-query' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DevicePage from '../page' + +const mockPush = vi.fn() +const mockReplace = vi.fn() +const mockDeviceLookup = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useSearchParams: () => ({ get: () => null }), + useRouter: () => ({ push: mockPush, replace: mockReplace }), + usePathname: () => '/device', +})) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal<typeof import('@tanstack/react-query')>() + return { + ...actual, + useQuery: vi.fn(), + } +}) + +vi.mock('@/service/device-flow', () => ({ + deviceLookup: (...args: unknown[]) => mockDeviceLookup(...args), + DeviceFlowError: class extends Error { + code: string + status: number + constructor(code: string, status = 400) { + super(code) + this.code = code + this.status = status + } + }, +})) + +vi.mock('@/service/system-features', () => ({ + systemFeaturesQueryOptions: () => ({ queryKey: ['sys'], queryFn: async () => ({}) }), +})) + +vi.mock('@/service/use-common', () => ({ + userProfileQueryOptions: () => ({ queryKey: ['profile'], queryFn: async () => null }), + commonQueryKeys: { currentWorkspace: ['currentWorkspace'] }, +})) + +const mockUseQuery = vi.mocked(useQuery) + +const VALID_CODE = 'ABCD-3456' + +// Typed reference to the mocked DeviceFlowError — same module reference as classifyLookupError uses +type MockDeviceFlowErrorCtor = new (code: string, status: number) => Error +let MockDeviceFlowError: MockDeviceFlowErrorCtor + +beforeEach(async () => { + vi.clearAllMocks() + mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>) + const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor } + MockDeviceFlowError = mod.DeviceFlowError +}) + +async function reachTerminal(rejectWith: unknown) { + mockDeviceLookup.mockRejectedValue(rejectWith) + render(<DevicePage />) + const input = screen.getByRole('textbox') + fireEvent.change(input, { target: { value: VALID_CODE } }) + fireEvent.click(screen.getByRole('button', { name: /Continue/i })) +} + +describe('error_expired terminal state', () => { + it('shows "Code no longer valid" heading', async () => { + await reachTerminal(new Error('expired')) + await screen.findByText('Code no longer valid') + }) + + it('ghost button resets to code_entry', async () => { + await reachTerminal(new Error('expired')) + await screen.findByText('Code no longer valid') + fireEvent.click(screen.getByRole('button', { name: /Try a different code/i })) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('Code no longer valid')).not.toBeInTheDocument() + }) +}) + +describe('error_rate_limited terminal state', () => { + it('shows "Too many attempts" heading', async () => { + await reachTerminal(new MockDeviceFlowError('rate_limited', 429)) + await screen.findByText('Too many attempts') + }) + + it('ghost button resets to code_entry', async () => { + await reachTerminal(new MockDeviceFlowError('rate_limited', 429)) + await screen.findByText('Too many attempts') + fireEvent.click(screen.getByRole('button', { name: /Try again/i })) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('Too many attempts')).not.toBeInTheDocument() + }) +}) + +describe('error_lookup_failed terminal state', () => { + it('shows "Could not verify the code" heading', async () => { + await reachTerminal(new MockDeviceFlowError('server_error', 500)) + await screen.findByText('Could not verify the code') + }) + + it('ghost button resets to code_entry', async () => { + await reachTerminal(new MockDeviceFlowError('server_error', 500)) + await screen.findByText('Could not verify the code') + fireEvent.click(screen.getByRole('button', { name: /Try again/i })) + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/device/_header.tsx b/web/app/device/_header.tsx new file mode 100644 index 0000000000..f3880db64c --- /dev/null +++ b/web/app/device/_header.tsx @@ -0,0 +1,50 @@ +'use client' +import { useSuspenseQuery } from '@tanstack/react-query' +import Divider from '@/app/components/base/divider' +import LocaleMenu from '@/app/signin/_locale-menu' +import { useLocale } from '@/context/i18n' +import { setLocaleOnClient } from '@/i18n-config' +import { languages } from '@/i18n-config/language' +import dynamic from '@/next/dynamic' +import { systemFeaturesQueryOptions } from '@/service/system-features' + +const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), { + ssr: false, + loading: () => <div className="h-7 w-16 bg-transparent" />, +}) +const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector'), { + ssr: false, + loading: () => <div className="size-8 bg-transparent" />, +}) + +const Header = () => { + const locale = useLocale() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + + return ( + <div className="flex w-full items-center justify-between p-6"> + {systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo + ? ( + <img + src={systemFeatures.branding.login_page_logo} + className="block h-7 w-auto object-contain" + alt="logo" + /> + ) + : <DifyLogo size="large" />} + <div className="flex items-center gap-1"> + <LocaleMenu + value={locale} + items={languages.filter(item => item.supported)} + onChange={(value) => { + setLocaleOnClient(value, false) + }} + /> + <Divider type="vertical" className="mx-0 ml-2 h-4" /> + <ThemeSelector /> + </div> + </div> + ) +} + +export default Header diff --git a/web/app/device/components/__tests__/authorize-account.spec.tsx b/web/app/device/components/__tests__/authorize-account.spec.tsx new file mode 100644 index 0000000000..f910461f93 --- /dev/null +++ b/web/app/device/components/__tests__/authorize-account.spec.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AuthorizeAccount from '../authorize-account' + +const mockApproveAccount = vi.fn() +const mockDenyAccount = vi.fn() + +vi.mock('@/service/device-flow', () => ({ + deviceApproveAccount: (...args: unknown[]) => mockApproveAccount(...args), + deviceDenyAccount: (...args: unknown[]) => mockDenyAccount(...args), + DeviceFlowError: class extends Error { + code: string + status: number + constructor(code: string, status = 400) { + super(code) + this.code = code + this.status = status + } + }, +})) + +const makeProps = () => ({ + userCode: 'ABCD-3456', + accountEmail: 'gareth@example.com', + accountName: 'Gareth Chen', + accountAvatarUrl: null, + defaultWorkspace: 'Dify Enterprise', + onApproved: vi.fn(), + onDenied: vi.fn(), + onError: vi.fn(), +}) + +describe('AuthorizeAccount', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApproveAccount.mockResolvedValue(undefined) + mockDenyAccount.mockResolvedValue(undefined) + }) + + it('renders accountName', () => { + render(<AuthorizeAccount {...makeProps()} />) + expect(screen.getByText('Gareth Chen')).toBeInTheDocument() + }) + + it('renders accountEmail', () => { + render(<AuthorizeAccount {...makeProps()} />) + expect(screen.getByText('gareth@example.com')).toBeInTheDocument() + }) + + it('renders defaultWorkspace', () => { + render(<AuthorizeAccount {...makeProps()} />) + expect(screen.getByText(/Dify Enterprise/)).toBeInTheDocument() + }) + + it('calls deviceApproveAccount with userCode on Authorize click', async () => { + render(<AuthorizeAccount {...makeProps()} />) + fireEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(mockApproveAccount).toHaveBeenCalledWith('ABCD-3456')) + }) + + it('calls onApproved after successful approve', async () => { + const props = makeProps() + render(<AuthorizeAccount {...props} />) + fireEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(props.onApproved).toHaveBeenCalled()) + }) + + it('calls deviceDenyAccount with userCode on Cancel click', async () => { + render(<AuthorizeAccount {...makeProps()} />) + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) + await waitFor(() => expect(mockDenyAccount).toHaveBeenCalledWith('ABCD-3456')) + }) + + it('calls onDenied after successful deny', async () => { + const props = makeProps() + render(<AuthorizeAccount {...props} />) + fireEvent.click(screen.getByRole('button', { name: /Cancel/i })) + await waitFor(() => expect(props.onDenied).toHaveBeenCalled()) + }) + + it('calls onError when approve throws', async () => { + mockApproveAccount.mockRejectedValue(new Error('unexpected')) + const props = makeProps() + render(<AuthorizeAccount {...props} />) + fireEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(props.onError).toHaveBeenCalledWith(expect.any(String))) + }) +}) diff --git a/web/app/device/components/__tests__/authorize-sso.spec.tsx b/web/app/device/components/__tests__/authorize-sso.spec.tsx new file mode 100644 index 0000000000..53f26b0f52 --- /dev/null +++ b/web/app/device/components/__tests__/authorize-sso.spec.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AuthorizeSSO from '../authorize-sso' + +const mockCtx = { + subject_email: 'gareth@company.com', + subject_issuer: 'Okta (okta.company.com)', + user_code: 'ABCD-3456', + csrf_token: 'tok', + expires_at: '2099-01-01T00:00:00Z', +} + +const mockFetchApprovalContext = vi.fn() +const mockApproveExternal = vi.fn() + +vi.mock('@/service/device-flow', () => ({ + fetchApprovalContext: () => mockFetchApprovalContext(), + approveExternal: (...args: unknown[]) => mockApproveExternal(...args), + DeviceFlowError: class extends Error { + code: string + status: number + constructor(code: string, status = 400) { + super(code) + this.code = code + this.status = status + } + }, +})) + +describe('AuthorizeSSO', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetchApprovalContext.mockResolvedValue(mockCtx) + mockApproveExternal.mockResolvedValue(undefined) + }) + + it('renders subject_email and issuer after context loads', async () => { + render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) + await screen.findByText('gareth@company.com') + expect(screen.getByText('Okta (okta.company.com)')).toBeInTheDocument() + }) + + it('renders single Authorize button with no Cancel', async () => { + render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) + await screen.findByRole('button', { name: /Authorize/i }) + expect(screen.queryByRole('button', { name: /Cancel/i })).not.toBeInTheDocument() + }) + + it('calls approveExternal with ctx and user_code on Authorize click', async () => { + render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) + await screen.findByRole('button', { name: /Authorize/i }) + await userEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(mockApproveExternal).toHaveBeenCalledWith(mockCtx, mockCtx.user_code)) + }) + + it('calls onApproved after successful approve', async () => { + const onApproved = vi.fn() + render(<AuthorizeSSO onApproved={onApproved} onError={vi.fn()} />) + await screen.findByRole('button', { name: /Authorize/i }) + await userEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(onApproved).toHaveBeenCalled()) + }) + + it('shows loadErr fallback when fetchApprovalContext rejects', async () => { + mockFetchApprovalContext.mockRejectedValue(new Error('network')) + render(<AuthorizeSSO onApproved={vi.fn()} onError={vi.fn()} />) + await screen.findByText('This session is no longer valid') + }) + + it('calls onError when approveExternal throws', async () => { + mockApproveExternal.mockRejectedValue(new Error('unexpected')) + const onError = vi.fn() + render(<AuthorizeSSO onApproved={vi.fn()} onError={onError} />) + await screen.findByRole('button', { name: /Authorize/i }) + await userEvent.click(screen.getByRole('button', { name: /Authorize/i })) + await waitFor(() => expect(onError).toHaveBeenCalledWith(expect.any(String))) + }) +}) diff --git a/web/app/device/components/__tests__/chooser.spec.tsx b/web/app/device/components/__tests__/chooser.spec.tsx new file mode 100644 index 0000000000..e3b152daa2 --- /dev/null +++ b/web/app/device/components/__tests__/chooser.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import Chooser from '../chooser' + +const mockPush = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('@/app/signin/utils/post-login-redirect', () => ({ + setPostLoginRedirect: vi.fn(), +})) + +describe('Chooser', () => { + it('renders account button', () => { + render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />) + expect(screen.getByRole('button', { name: /Sign in with Dify account/i })).toBeInTheDocument() + }) + + it('hides SSO button when ssoAvailable is false', () => { + render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />) + expect(screen.queryByRole('button', { name: /Sign in with SSO/i })).not.toBeInTheDocument() + }) + + it('shows SSO button when ssoAvailable is true', () => { + render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />) + expect(screen.getByRole('button', { name: /Sign in with SSO/i })).toBeInTheDocument() + }) + + it('sets post-login redirect and navigates to /signin on account button click', () => { + render(<Chooser userCode="ABCD-3456" ssoAvailable={false} />) + fireEvent.click(screen.getByRole('button', { name: /Sign in with Dify account/i })) + expect(vi.mocked(setPostLoginRedirect)).toHaveBeenCalledWith('/device?user_code=ABCD-3456') + expect(mockPush).toHaveBeenCalledWith('/signin') + }) + + it('encodes userCode in post-login redirect', () => { + // Uses a code with a space to exercise encodeURIComponent + render(<Chooser userCode="AB CD" ssoAvailable={false} />) + fireEvent.click(screen.getByRole('button', { name: /Sign in with Dify account/i })) + expect(vi.mocked(setPostLoginRedirect)).toHaveBeenCalledWith('/device?user_code=AB%20CD') + }) + + it('navigates to SSO initiate URL on SSO button click', () => { + Object.defineProperty(window, 'location', { + writable: true, + value: { href: '' }, + }) + render(<Chooser userCode="ABCD-3456" ssoAvailable={true} />) + fireEvent.click(screen.getByRole('button', { name: /Sign in with SSO/i })) + expect(window.location.href).toBe( + '/openapi/v1/oauth/device/sso-initiate?user_code=ABCD-3456', + ) + }) +}) diff --git a/web/app/device/components/authorize-account.tsx b/web/app/device/components/authorize-account.tsx new file mode 100644 index 0000000000..267ef3d9a5 --- /dev/null +++ b/web/app/device/components/authorize-account.tsx @@ -0,0 +1,121 @@ +'use client' + +import type { FC } from 'react' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { useState } from 'react' +import { deviceApproveAccount, deviceDenyAccount } from '@/service/device-flow' +import { approveErrorCopy } from '../utils/error-copy' + +type Props = { + userCode: string + accountEmail?: string + accountName?: string + accountAvatarUrl?: string | null + defaultWorkspace?: string + onApproved: () => void + onDenied: () => void + onError: (message: string) => void +} + +/** + * AuthorizeAccount is the account-branch authorize screen. Called with a + * live console session already established (user bounced through /signin). + * Posts to /openapi/v1/oauth/device/{approve,deny}; these endpoints mint + * the dfoa_ token server-side. + */ +const AuthorizeAccount: FC<Props> = ({ + userCode, + accountEmail, + accountName, + accountAvatarUrl, + defaultWorkspace, + onApproved, + onDenied, + onError, +}) => { + const [busy, setBusy] = useState(false) + + const approve = async () => { + setBusy(true) + try { + await deviceApproveAccount(userCode) + onApproved() + } + catch (e) { + onError(approveErrorCopy(e)) + } + finally { + setBusy(false) + } + } + + const deny = async () => { + setBusy(true) + try { + await deviceDenyAccount(userCode) + onDenied() + } + catch (e) { + onError(approveErrorCopy(e)) + } + finally { + setBusy(false) + } + } + + return ( + <div className="flex flex-col gap-5"> + <div> + <h2 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h2> + <p className="mt-2 text-sm text-text-secondary"> + difyctl is requesting access. If you didn't start this from your terminal, click Cancel. + </p> + </div> + <div className="flex items-center gap-2.5 rounded-lg bg-background-section-burn px-3 py-2.5"> + <Avatar + size="md" + avatar={accountAvatarUrl ?? null} + name={accountName || accountEmail || ''} + /> + <div> + {accountName && ( + <p className="text-sm font-semibold text-text-primary">{accountName}</p> + )} + {accountEmail && ( + <p className="text-xs text-text-secondary">{accountEmail}</p> + )} + </div> + </div> + {defaultWorkspace && ( + <div className="rounded-lg bg-background-section-burn px-3 py-2 text-sm text-text-secondary"> + Workspace: + {' '} + <span className="font-semibold text-text-primary">{defaultWorkspace}</span> + </div> + )} + <div className="flex gap-3"> + <Button + variant="primary" + size="large" + className="flex-1" + onClick={approve} + disabled={busy} + > + Authorize + </Button> + <Button + variant="secondary" + size="large" + className="flex-1" + onClick={deny} + disabled={busy} + > + Cancel + </Button> + </div> + </div> + ) +} + +export default AuthorizeAccount diff --git a/web/app/device/components/authorize-sso.tsx b/web/app/device/components/authorize-sso.tsx new file mode 100644 index 0000000000..3499465a91 --- /dev/null +++ b/web/app/device/components/authorize-sso.tsx @@ -0,0 +1,124 @@ +'use client' + +import type { FC } from 'react' +import type { ApprovalContext } from '@/service/device-flow' +import { Avatar } from '@langgenius/dify-ui/avatar' +import { Button } from '@langgenius/dify-ui/button' +import { useEffect, useState } from 'react' +import { approveExternal, fetchApprovalContext } from '@/service/device-flow' +import { approveErrorCopy } from '../utils/error-copy' + +type Props = { + onApproved: () => void + onError: (message: string) => void +} + +/** + * AuthorizeSSO is the external-SSO branch authorize screen. On mount it + * fetches /openapi/v1/oauth/device/approval-context to learn subject_email, + * issuer, user_code, and csrf_token from the device_approval_grant cookie. + * On Approve click, posts /openapi/v1/oauth/device/approve-external with + * the CSRF header. + * + * The user_code in state is bound to the cookie by server; we do not accept + * one from the URL because the SSO branch deliberately detaches from the + * pre-SSO ?user_code=... query param. + */ +const AuthorizeSSO: FC<Props> = ({ onApproved, onError }) => { + const [ctx, setCtx] = useState<ApprovalContext | null>(null) + const [busy, setBusy] = useState(false) + const [loadErr, setLoadErr] = useState<string | null>(null) + + useEffect(() => { + let cancelled = false + fetchApprovalContext() + .then((c) => { + if (!cancelled) + setCtx(c) + }) + .catch((e) => { + if (!cancelled) + setLoadErr(approveErrorCopy(e)) + }) + return () => { + cancelled = true + } + }, []) + + const approve = async () => { + if (!ctx) + return + setBusy(true) + try { + await approveExternal(ctx, ctx.user_code) + onApproved() + } + catch (e) { + onError(approveErrorCopy(e)) + } + finally { + setBusy(false) + } + } + + // loadErr and loading states render without the icon-circle pattern intentionally — + // they occur before the SSO identity is established, so there is no terminal + // state to decorate. The page.tsx error_* states cover post-lookup failures. + if (loadErr) { + return ( + <div> + <h2 className="text-2xl font-semibold text-text-primary">This session is no longer valid</h2> + <p className="mt-2 text-sm text-text-secondary"> + Run + {' '} + <code className="rounded bg-components-input-bg-normal px-1 font-mono">difyctl auth login</code> + {' '} + again to start a new sign-in. + </p> + </div> + ) + } + if (!ctx) { + return <div className="text-sm text-text-secondary">Loading session…</div> + } + + return ( + <div className="flex flex-col gap-5"> + <div> + <h2 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h2> + <p className="mt-2 text-sm text-text-secondary"> + difyctl is requesting access via SSO. If you didn't start this from your terminal, close this tab. + </p> + </div> + <div className="flex items-center gap-2.5 rounded-lg bg-background-section-burn px-3 py-2.5"> + <Avatar + size="md" + avatar={null} + name={ctx.subject_email} + /> + <div> + <p className="text-sm font-semibold text-text-primary">{ctx.subject_email}</p> + <p className="text-xs text-text-secondary">via SSO</p> + </div> + </div> + {ctx.subject_issuer && ( + <div className="rounded-lg bg-background-section-burn px-3 py-2 text-sm text-text-secondary"> + Identity provider: + {' '} + <span className="font-semibold text-text-primary">{ctx.subject_issuer}</span> + </div> + )} + <Button + variant="primary" + size="large" + className="w-full" + onClick={approve} + disabled={busy} + > + Authorize + </Button> + </div> + ) +} + +export default AuthorizeSSO diff --git a/web/app/device/components/chooser.tsx b/web/app/device/components/chooser.tsx new file mode 100644 index 0000000000..6c68f5b4b3 --- /dev/null +++ b/web/app/device/components/chooser.tsx @@ -0,0 +1,64 @@ +'use client' + +import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { useRouter } from '@/next/navigation' + +type Props = { + userCode: string + ssoAvailable: boolean +} + +/** + * Chooser renders the two-button device-auth login selector. Account button + * seeds postLoginRedirect + navigates to /signin so every existing account + * login method (password / email-code / social OAuth / account-SSO) flows + * through its usual plumbing. SSO button hits /openapi/v1/oauth/device/sso-initiate + * directly — the SSO branch skips /signin entirely. + * + * v1.0 scope: only account-SSO honours postLoginRedirect (via sso-auth's + * return_to plumbing). Password / email-code / social-OAuth users land on + * /signin's default post-login target and manually return to the /device + * URL printed by the CLI. That's not great UX; a follow-up milestone + * generalises post-signin redirect to all methods. + */ +const Chooser: FC<Props> = ({ userCode, ssoAvailable }) => { + const router = useRouter() + + const onAccount = () => { + setPostLoginRedirect(`/device?user_code=${encodeURIComponent(userCode)}`) + router.push('/signin') + } + + const onSSO = () => { + window.location.href = `/openapi/v1/oauth/device/sso-initiate?user_code=${encodeURIComponent(userCode)}` + } + + return ( + <div className="flex flex-col gap-3"> + <Button + variant="primary" + size="large" + className="w-full gap-2" + onClick={onAccount} + > + <span className="i-ri-user-3-line h-4 w-4" /> + Sign in with Dify account + </Button> + {ssoAvailable && ( + <Button + variant="secondary" + size="large" + className="w-full gap-2" + onClick={onSSO} + > + <span className="i-ri-shield-line h-4 w-4" /> + Sign in with SSO + </Button> + )} + </div> + ) +} + +export default Chooser diff --git a/web/app/device/components/code-input.tsx b/web/app/device/components/code-input.tsx new file mode 100644 index 0000000000..ae5dc850dd --- /dev/null +++ b/web/app/device/components/code-input.tsx @@ -0,0 +1,45 @@ +'use client' + +import type { FC } from 'react' +import { useCallback } from 'react' +import { normaliseUserCodeInput } from '../utils/user-code' + +type Props = { + value: string + onChange: (normalised: string) => void + disabled?: boolean + autoFocus?: boolean +} + +/** + * CodeInput renders the user_code text field with live normalisation + * (uppercase, reduced alphabet, XXXX-XXXX hyphenation). + * + * The onChange callback receives the normalised value only — the parent does + * not need to run validation itself. + */ +const CodeInput: FC<Props> = ({ value, onChange, disabled, autoFocus }) => { + const handle = useCallback((raw: string) => { + onChange(normaliseUserCodeInput(raw)) + }, [onChange]) + + return ( + <input + type="text" + inputMode="text" + autoCapitalize="characters" + autoComplete="off" + spellCheck={false} + placeholder="ABCD-1234" + maxLength={9} + aria-label="one-time code" + className="border-components-input-border-normal w-full rounded-lg border bg-components-input-bg-normal px-4 py-3 text-center font-mono text-2xl tracking-wider text-text-primary focus:border-components-input-border-active focus:outline-none" + value={value} + disabled={disabled} + autoFocus={autoFocus} + onChange={e => handle(e.target.value)} + /> + ) +} + +export default CodeInput diff --git a/web/app/device/layout.tsx b/web/app/device/layout.tsx new file mode 100644 index 0000000000..fe4b759dac --- /dev/null +++ b/web/app/device/layout.tsx @@ -0,0 +1,32 @@ +'use client' +import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' +import useDocumentTitle from '@/hooks/use-document-title' +import { systemFeaturesQueryOptions } from '@/service/system-features' +import Header from './_header' + +export default function DeviceLayout({ children }: { children: React.ReactNode }) { + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + useDocumentTitle('') + return ( + <div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}> + <div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}> + <Header /> + <div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}> + <div className="flex flex-col md:w-[400px]"> + {children} + </div> + </div> + {systemFeatures.branding.enabled === false && ( + <div className="px-8 py-6 system-xs-regular text-text-tertiary"> + © + {' '} + {new Date().getFullYear()} + {' '} + LangGenius, Inc. All rights reserved. + </div> + )} + </div> + </div> + ) +} diff --git a/web/app/device/page.tsx b/web/app/device/page.tsx new file mode 100644 index 0000000000..83def36a75 --- /dev/null +++ b/web/app/device/page.tsx @@ -0,0 +1,273 @@ +'use client' + +import type { ICurrentWorkspace } from '@/models/common' +import { Button } from '@langgenius/dify-ui/button' +import { useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import Divider from '@/app/components/base/divider' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' +import { post } from '@/service/base' +import { deviceLookup } from '@/service/device-flow' +import { systemFeaturesQueryOptions } from '@/service/system-features' +import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common' +import AuthorizeAccount from './components/authorize-account' +import AuthorizeSSO from './components/authorize-sso' +import Chooser from './components/chooser' +import CodeInput from './components/code-input' +import { classifyLookupError } from './utils/error-copy' +import { isValidUserCode } from './utils/user-code' + +type View + = | { kind: 'code_entry' } + | { kind: 'chooser', userCode: string } + | { kind: 'authorize_account', userCode: string } + | { kind: 'authorize_sso' } + | { kind: 'success' } + | { kind: 'error_expired' } + | { kind: 'error_rate_limited' } + | { kind: 'error_lookup_failed' } + +export default function DevicePage() { + const searchParams = useSearchParams() + const router = useRouter() + const pathname = usePathname() + const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase() + const ssoVerified = searchParams.get('sso_verified') === '1' + + const [typed, setTyped] = useState('') + const [view, setView] = useState<View>({ kind: 'code_entry' }) + const [errMsg, setErrMsg] = useState<string | null>(null) + + // Account subject + workspace identity (for the authorize-account screen). + // Logged-out is a valid landing state on /device — disable refetch storms + // and skip workspace probe until profile resolves (avoids /current + chained + // /refresh-token 401 loops while the user is still entering the code). + const { data: userResp, isError: profileErr } = useQuery({ + ...userProfileQueryOptions(), + throwOnError: false, + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + }) + const account = userResp?.profile + const { data: currentWorkspace } = useQuery<ICurrentWorkspace>({ + queryKey: commonQueryKeys.currentWorkspace, + queryFn: () => post<ICurrentWorkspace>('/workspaces/current'), + enabled: !!account && !profileErr, + retry: false, + refetchOnWindowFocus: false, + }) + const { data: sys } = useQuery(systemFeaturesQueryOptions()) + // Device-flow SSO branch uses external-user (webapp) SSO, not console SSO — + // backend mints EXTERNAL_SSO tokens via Enterprise's external ACS. Gate on + // webapp_auth.{enabled, allow_sso} + a configured webapp SSO protocol. + const ssoAvailable = !!sys?.webapp_auth?.enabled + && !!sys?.webapp_auth?.allow_sso + && (sys?.webapp_auth?.sso_config?.protocol || '') !== '' + + // URL-driven view transitions. Only advances while the user is still on + // the entry/chooser screens — never clobbers terminal views (success / + // error_expired / authorize_*) when userProfile refetches. + // After consuming the params, scrub them from the URL so they don't + // leak via history / Referer / server logs (RFC 8628 §5.4). + useEffect(() => { + if (view.kind !== 'code_entry' && view.kind !== 'chooser') + return + // Post-login bounce: chooser holds the typed code, account just loaded. + // The URL was already scrubbed on the first effect run, so urlUserCode + // is empty here — advance using the userCode stashed in view state. + if (view.kind === 'chooser' && account) { + setView({ kind: 'authorize_account', userCode: view.userCode }) // eslint-disable-line react/set-state-in-effect + return + } + let consumed = false + if (ssoVerified) { + setView({ kind: 'authorize_sso' }) // eslint-disable-line react/set-state-in-effect + consumed = true + } + else if (urlUserCode && isValidUserCode(urlUserCode)) { + if (account) + setView({ kind: 'authorize_account', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect + else + setView({ kind: 'chooser', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect + consumed = true + } + if (consumed && (urlUserCode || ssoVerified)) + router.replace(pathname) + }, [urlUserCode, ssoVerified, account, view, router, pathname]) + + const onContinue = async () => { + if (!isValidUserCode(typed)) + return + try { + const reply = await deviceLookup(typed) + if (!reply.valid) { + setView({ kind: 'error_expired' }) + return + } + } + catch (e) { + const outcome = classifyLookupError(e) + if (outcome === 'rate_limited') + setView({ kind: 'error_rate_limited' }) + else if (outcome === 'failed') + setView({ kind: 'error_lookup_failed' }) + else + setView({ kind: 'error_expired' }) + return + } + if (account) + setView({ kind: 'authorize_account', userCode: typed }) + else setView({ kind: 'chooser', userCode: typed }) + } + + return ( + <> + {view.kind === 'code_entry' && ( + <div className="flex flex-col gap-5"> + <div> + <h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1> + <p className="mt-2 text-sm text-text-secondary"> + Enter the code shown in your terminal. + </p> + </div> + <CodeInput value={typed} onChange={setTyped} autoFocus /> + <Button + variant="primary" + size="large" + className="w-full" + onClick={onContinue} + disabled={!isValidUserCode(typed)} + > + Continue + </Button> + </div> + )} + + {view.kind === 'chooser' && ( + <div className="flex flex-col gap-5"> + <div> + <h1 className="text-2xl font-semibold text-text-primary">Sign in to authorize</h1> + <p className="mt-2 text-sm text-text-secondary"> + Code + {' '} + <code className="rounded bg-components-input-bg-normal px-1 font-mono">{view.userCode}</code> + {' '} + is valid. Choose how to sign in. + </p> + </div> + <Chooser userCode={view.userCode} ssoAvailable={ssoAvailable} /> + </div> + )} + + {view.kind === 'authorize_account' && ( + <AuthorizeAccount + userCode={view.userCode} + accountEmail={account?.email} + accountName={account?.name} + accountAvatarUrl={account?.avatar_url ?? null} + defaultWorkspace={currentWorkspace?.name} + onApproved={() => setView({ kind: 'success' })} + onDenied={() => setView({ kind: 'error_expired' })} + onError={e => setErrMsg(e)} + /> + )} + + {view.kind === 'authorize_sso' && ( + <AuthorizeSSO + onApproved={() => setView({ kind: 'success' })} + onError={e => setErrMsg(e)} + /> + )} + + {view.kind === 'success' && ( + <div className="flex flex-col gap-1"> + <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-success-hover"> + <span className="i-ri-checkbox-circle-line h-[18px] w-[18px] text-util-colors-green-green-600" /> + </div> + <h1 className="text-xl font-semibold text-text-primary">You're signed in</h1> + <p className="text-sm text-text-secondary">Return to your terminal to continue.</p> + <Divider className="my-3" /> + <Button variant="ghost" className="w-full" onClick={() => router.push('/apps')}> + Go to Dify console → + </Button> + </div> + )} + + {view.kind === 'error_expired' && ( + <div className="flex flex-col gap-1"> + <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-warning-hover"> + <span className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" /> + </div> + <h1 className="text-xl font-semibold text-text-primary">Code no longer valid</h1> + <p className="text-sm text-text-secondary"> + Expired or already used. Run + {' '} + <code className="rounded bg-components-input-bg-normal px-1 font-mono">difyctl auth login</code> + {' '} + to get a new code. + </p> + <Divider className="my-3" /> + <Button + variant="ghost" + className="w-full" + onClick={() => { + setView({ kind: 'code_entry' }) + setErrMsg(null) + }} + > + ← Try a different code + </Button> + </div> + )} + + {view.kind === 'error_rate_limited' && ( + <div className="flex flex-col gap-1"> + <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-warning-hover"> + <span className="i-ri-error-warning-line h-[18px] w-[18px] text-util-colors-yellow-yellow-600" /> + </div> + <h1 className="text-xl font-semibold text-text-primary">Too many attempts</h1> + <p className="text-sm text-text-secondary">Wait a moment and try again.</p> + <Divider className="my-3" /> + <Button + variant="ghost" + className="w-full" + onClick={() => { + setView({ kind: 'code_entry' }) + setErrMsg(null) + }} + > + ← Try again + </Button> + </div> + )} + + {view.kind === 'error_lookup_failed' && ( + <div className="flex flex-col gap-1"> + <div className="mb-2.5 flex h-[38px] w-[38px] items-center justify-center rounded-full bg-state-destructive-hover"> + <span className="i-ri-close-circle-line h-[18px] w-[18px] text-util-colors-red-red-600" /> + </div> + <h1 className="text-xl font-semibold text-text-primary">Could not verify the code</h1> + <p className="text-sm text-text-secondary"> + Something went wrong on our side. Try again in a moment. + </p> + <Divider className="my-3" /> + <Button + variant="ghost" + className="w-full" + onClick={() => { + setView({ kind: 'code_entry' }) + setErrMsg(null) + }} + > + ← Try again + </Button> + </div> + )} + + {errMsg && ( + <p className="mt-4 text-sm text-text-destructive">{errMsg}</p> + )} + </> + ) +} diff --git a/web/app/device/utils/error-copy.ts b/web/app/device/utils/error-copy.ts new file mode 100644 index 0000000000..9360fb167e --- /dev/null +++ b/web/app/device/utils/error-copy.ts @@ -0,0 +1,43 @@ +// Translate a DeviceFlowError (or any thrown value) into user-facing copy. +// Centralised so account/SSO branches surface the same words for the same +// failure mode and so a new server error code can be wired up here once. + +import { DeviceFlowError } from '@/service/device-flow' + +const APPROVE_COPY: Record<string, string> = { + rate_limited: 'Too many attempts. Wait a moment and try again.', + no_session: 'Your session has expired. Run difyctl auth login again to start over.', + invalid_session: 'Your session has expired. Run difyctl auth login again to start over.', + session_already_consumed: 'This session was already used. Run difyctl auth login again.', + csrf_mismatch: 'Could not verify the request. Refresh the page and try again.', + forbidden: 'Could not verify the request. Refresh the page and try again.', + expired_or_unknown: 'This code is no longer valid.', + not_found: 'This code is no longer valid.', + user_code_mismatch: 'This code does not match the active session. Run difyctl auth login again.', + user_code_not_pending: 'This code was already approved or denied.', + already_resolved: 'This code was already approved or denied.', + state_lost: 'The flow expired before approval completed. Run difyctl auth login again.', + approve_in_progress: 'An approval is already in progress for this code.', + conflict: 'This code is no longer in a state we can approve.', + server_error: 'Something went wrong on our side. Try again in a moment.', +} + +const DEFAULT_MESSAGE = 'Could not complete the request. Please try again.' + +export function approveErrorCopy(err: unknown): string { + if (err instanceof DeviceFlowError) + return APPROVE_COPY[err.code] ?? DEFAULT_MESSAGE + return DEFAULT_MESSAGE +} + +export type LookupOutcome = 'expired' | 'rate_limited' | 'failed' + +export function classifyLookupError(err: unknown): LookupOutcome { + if (err instanceof DeviceFlowError) { + if (err.code === 'rate_limited' || err.status === 429) + return 'rate_limited' + if (err.code === 'server_error' || err.status >= 500) + return 'failed' + } + return 'expired' +} diff --git a/web/app/device/utils/user-code.ts b/web/app/device/utils/user-code.ts new file mode 100644 index 0000000000..30281ae087 --- /dev/null +++ b/web/app/device/utils/user-code.ts @@ -0,0 +1,37 @@ +// user-code.ts — input normalisation + validation for the RFC 8628 +// 8-character user_code format the CLI prints to stderr. +// +// Format: XXXX-XXXX, uppercase, reduced alphabet (no 0/O, 1/I/l, 2/Z). Low +// entropy by design — humans type it — so the server-side rate-limit + TTL + +// single-use properties are what defend it, not the alphabet. + +const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXY3456789' // excludes 0 O 1 I L 2 Z + +/** + * normaliseUserCodeInput prepares raw input for display in the code field: + * strips non-alphanumerics, uppercases, drops disallowed characters, and + * inserts the hyphen after the fourth accepted char. + * + * Returns at most 9 chars ("XXXX-XXXX"); longer input is truncated. + */ +export function normaliseUserCodeInput(raw: string): string { + const cleaned: string[] = [] + for (const ch of raw.toUpperCase()) { + if (USER_CODE_ALPHABET.includes(ch)) + cleaned.push(ch) + if (cleaned.length === 8) + break + } + if (cleaned.length <= 4) + return cleaned.join('') + return `${cleaned.slice(0, 4).join('')}-${cleaned.slice(4).join('')}` +} + +/** + * isValidUserCode tests whether the normalised form is a complete XXXX-XXXX + * token suitable for submission to /openapi/v1/oauth/device/lookup. + */ +export function isValidUserCode(normalised: string): boolean { + return /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(normalised) + && [...normalised.replace('-', '')].every(c => USER_CODE_ALPHABET.includes(c)) +} diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index 0015296a41..2c8d82d9bb 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -2,6 +2,13 @@ import type { ReadonlyURLSearchParams } from '@/next/navigation' const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending_redirect' const REDIRECT_URL_KEY = 'redirect_url' +const DEVICE_REDIRECT_KEY = 'dify_post_login_redirect' +const DEVICE_TTL_MS = 15 * 60 * 1000 + +const ALLOWED: Record<string, ReadonlySet<string>> = { + '/device': new Set(['user_code', 'sso_verified']), + '/account/oauth/authorize': new Set(['client_id', 'scope', 'state', 'redirect_uri']), +} type OAuthPendingRedirect = { value?: string @@ -10,6 +17,76 @@ type OAuthPendingRedirect = { const getCurrentUnixTimestamp = () => Math.floor(Date.now() / 1000) +function validate(target: string): string | null { + if (typeof window === 'undefined') + return null + try { + const url = new URL(target, window.location.origin) + if (url.origin !== window.location.origin) + return null + const allowedKeys = ALLOWED[url.pathname] + if (!allowedKeys) + return null + for (const key of url.searchParams.keys()) { + if (!allowedKeys.has(key)) + return null + } + return url.pathname + (url.search || '') + } + catch { + return null + } +} + +// Persists target across full-page redirects within the same tab (social +// OAuth, SSO IdP bounce). sessionStorage is tab-scoped so concurrent +// /device tabs don't clobber each other. 15-min TTL drops stale values. +// Same-origin + exact-path whitelist prevents open-redirect. +export const setPostLoginRedirect = (value: string | null) => { + if (typeof window === 'undefined') + return + if (value === null) { + try { + sessionStorage.removeItem(DEVICE_REDIRECT_KEY) + } + catch {} + return + } + const safe = validate(value) + if (!safe) + return + try { + sessionStorage.setItem(DEVICE_REDIRECT_KEY, JSON.stringify({ target: safe, ts: Date.now() })) + } + catch {} +} + +function getDeviceRedirect(): string | null { + if (typeof window === 'undefined') + return null + let raw: string | null = null + try { + raw = sessionStorage.getItem(DEVICE_REDIRECT_KEY) + sessionStorage.removeItem(DEVICE_REDIRECT_KEY) + } + catch { + return null + } + if (!raw) + return null + try { + const parsed = JSON.parse(raw) + if (typeof parsed?.target !== 'string' || typeof parsed?.ts !== 'number') + return null + if (Date.now() - parsed.ts > DEVICE_TTL_MS) + return null + return validate(parsed.target) + } + catch { + return null + } +} + function removeOAuthPendingRedirect() { try { localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY) @@ -59,5 +136,8 @@ export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams) } } } + const device = getDeviceRedirect() + if (device) + return device return getOAuthPendingRedirect() } diff --git a/web/next.config.ts b/web/next.config.ts index a1c2e410a1..76df14396e 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -34,6 +34,17 @@ const nextConfig: NextConfig = { }, ] }, + // Deny framing on device-flow routes — no trusted embedder exists. + async headers() { + const antiFrame = [ + { key: 'X-Frame-Options', value: 'DENY' }, + { key: 'Content-Security-Policy', value: 'frame-ancestors \'none\'' }, + ] + return [ + { source: '/device', headers: antiFrame }, + { source: '/device/:path*', headers: antiFrame }, + ] + }, output: 'standalone', compiler: { removeConsole: isDev ? false : { exclude: ['warn', 'error'] }, diff --git a/web/service/base.ts b/web/service/base.ts index f382af81ac..7e35b3d789 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -808,6 +808,11 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT)) if (refreshErr === null) return baseFetch<T>(url, options, otherOptionsForBaseFetch) + // /device is the device-flow chooser; logged-out is a valid state + // there. Redirecting to /signin loses the user_code context and + // the post-login flow lands on /apps instead of returning here. + if (location.pathname === `${basePath}/device`) + return Promise.reject(err) if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) { jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) diff --git a/web/service/device-flow.ts b/web/service/device-flow.ts new file mode 100644 index 0000000000..2ae4c4ab92 --- /dev/null +++ b/web/service/device-flow.ts @@ -0,0 +1,151 @@ +// Web-side calls into the Dify device-flow endpoints. All routes now sit +// under /openapi/v1/oauth/device/* (Phase G of the openapi migration). The +// approve/deny endpoints still require the console session cookie + CSRF +// token; lookup is unauthenticated; the SSO branch uses cookie + per-flow +// CSRF baked into the approval-context response. +// +// /openapi/v1/oauth/device/lookup (public — GET) +// /openapi/v1/oauth/device/approve (cookie + CSRF — POST) +// /openapi/v1/oauth/device/deny (cookie + CSRF — POST) +// /openapi/v1/oauth/device/approval-context (cookie — GET) +// /openapi/v1/oauth/device/approve-external (cookie + per-flow CSRF — POST) +// +// /openapi/v1/* is its own URL prefix, so we bypass service/base's +// API_PREFIX (which targets /console/api) and call fetch directly. + +import Cookies from 'js-cookie' +import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config' + +const DEVICE_BASE = '/openapi/v1/oauth/device' + +// Typed error thrown by every wrapper here. The page/component layer +// switches on `code` to choose user-facing copy / view; never render +// `status` or raw body to the user. +export class DeviceFlowError extends Error { + code: string + status: number + + constructor(code: string, status: number) { + super(code) + this.name = 'DeviceFlowError' + this.code = code + this.status = status + } +} + +// Translate a non-2xx fetch Response into a DeviceFlowError. Honours the +// server contract `{"error": "<code>"}` and falls back to a status-class +// code so callers can still dispatch (rate_limited / server_error / ...). +async function failFromResponse(res: Response): Promise<never> { + let serverCode = '' + try { + const body = await res.clone().json() + if (body && typeof body.error === 'string') + serverCode = body.error + } + catch { /* non-JSON body — fall through to status mapping */ } + + const code = serverCode || statusFallbackCode(res.status) + throw new DeviceFlowError(code, res.status) +} + +function statusFallbackCode(status: number): string { + if (status === 429) + return 'rate_limited' + if (status === 401) + return 'no_session' + if (status === 403) + return 'forbidden' + if (status === 404) + return 'not_found' + if (status === 409) + return 'conflict' + if (status >= 500) + return 'server_error' + return 'unknown' +} + +function consoleCsrfHeader(): Record<string, string> { + return { [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '' } +} + +// ----- Account branch -------------------------------------------------------- + +export type DeviceLookupReply = { + valid: boolean + expires_in_remaining: number + client_id: string +} + +export async function deviceLookup(user_code: string): Promise<DeviceLookupReply> { + const res = await fetch(`${DEVICE_BASE}/lookup?user_code=${encodeURIComponent(user_code)}`, { + method: 'GET', + }) + if (!res.ok) + await failFromResponse(res) + return res.json() +} + +export async function deviceApproveAccount(user_code: string): Promise<{ status: 'approved' }> { + const res = await fetch(`${DEVICE_BASE}/approve`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...consoleCsrfHeader(), + }, + body: JSON.stringify({ user_code }), + }) + if (!res.ok) + await failFromResponse(res) + return res.json() +} + +export async function deviceDenyAccount(user_code: string): Promise<{ status: 'denied' }> { + const res = await fetch(`${DEVICE_BASE}/deny`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...consoleCsrfHeader(), + }, + body: JSON.stringify({ user_code }), + }) + if (!res.ok) + await failFromResponse(res) + return res.json() +} + +// ----- SSO branch (cookie-authed via /openapi/v1/oauth/device/*) ----------- + +export type ApprovalContext = { + subject_email: string + subject_issuer: string + user_code: string + csrf_token: string + expires_at: string +} + +export async function fetchApprovalContext(): Promise<ApprovalContext> { + const res = await fetch(`${DEVICE_BASE}/approval-context`, { + method: 'GET', + credentials: 'include', + }) + if (!res.ok) + await failFromResponse(res) + return res.json() +} + +export async function approveExternal(ctx: ApprovalContext, user_code: string): Promise<void> { + const res = await fetch(`${DEVICE_BASE}/approve-external`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': ctx.csrf_token, + }, + body: JSON.stringify({ user_code }), + }) + if (!res.ok) + await failFromResponse(res) +}