mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 14:16:23 +08:00
Compare commits
3 Commits
4-27-app-d
...
fix/cli-to
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c98692f7 | |||
| 2fc6febb3f | |||
| 0bbe60beb4 |
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
@ -12,7 +12,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
||||
- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
||||
|
||||
## Ownership
|
||||
@ -20,8 +19,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
||||
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
@ -32,9 +29,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
@ -51,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
## Component Boundaries
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
|
||||
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@ -9,7 +9,6 @@ on:
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-backend"
|
||||
- "4-27-app-deploy"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
@ -16,7 +16,6 @@ api = ExternalApi(
|
||||
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
|
||||
|
||||
from . import mail as _mail
|
||||
from . import runtime_credentials as _runtime_credentials
|
||||
from .app import dsl as _app_dsl
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import workspace as _workspace
|
||||
@ -27,7 +26,6 @@ __all__ = [
|
||||
"_app_dsl",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
"bp",
|
||||
|
||||
@ -1,200 +0,0 @@
|
||||
"""Inner API endpoints for runtime credential resolution.
|
||||
|
||||
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
|
||||
returns decrypted model and tool credentials for in-memory runtime use only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from json import JSONDecodeError
|
||||
from typing import Any
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.helper import encrypter
|
||||
from core.helper.provider_cache import ToolProviderCredentialsCache
|
||||
from core.helper.provider_encryption import create_provider_encrypter
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from extensions.ext_database import db
|
||||
from models.provider import ProviderCredential
|
||||
from models.tools import BuiltinToolProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KIND_MODEL = "model"
|
||||
_KIND_TOOL = "tool"
|
||||
|
||||
# (body, status) pair returned by a resolver helper when resolution fails.
|
||||
ResolveError = tuple[dict[str, str], int]
|
||||
|
||||
|
||||
class InnerRuntimeCredentialResolveItem(BaseModel):
|
||||
credential_id: str = Field(description="Credential id")
|
||||
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
|
||||
kind: str = Field(description="Credential kind, either 'model' or 'tool'")
|
||||
|
||||
|
||||
class InnerRuntimeCredentialsResolvePayload(BaseModel):
|
||||
tenant_id: str = Field(description="Workspace id")
|
||||
credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/runtime/credentials/resolve")
|
||||
class EnterpriseRuntimeCredentialsResolve(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_runtime_credentials_resolve",
|
||||
responses={
|
||||
200: "Credentials resolved",
|
||||
400: "Invalid request or credential config",
|
||||
404: "Provider or credential not found",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__])
|
||||
def post(self):
|
||||
args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
|
||||
if not args.credentials:
|
||||
return {"credentials": []}, 200
|
||||
|
||||
# Model resolution shares one provider configuration set; build it lazily
|
||||
# so a tool-only request never pays for the plugin daemon round trip.
|
||||
model_configurations = None
|
||||
|
||||
resolved: list[dict[str, Any]] = []
|
||||
for item in args.credentials:
|
||||
if item.kind == _KIND_MODEL:
|
||||
if model_configurations is None:
|
||||
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
|
||||
model_configurations = provider_manager.get_configurations(args.tenant_id)
|
||||
values, error = _resolve_model(args.tenant_id, model_configurations, item)
|
||||
elif item.kind == _KIND_TOOL:
|
||||
values, error = _resolve_tool(args.tenant_id, item)
|
||||
else:
|
||||
return {"message": f"unsupported credential kind '{item.kind}'"}, 400
|
||||
|
||||
if error is not None:
|
||||
return error
|
||||
resolved.append(
|
||||
{
|
||||
"credential_id": item.credential_id,
|
||||
"kind": item.kind,
|
||||
"provider": item.provider,
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
return {"credentials": resolved}, 200
|
||||
|
||||
|
||||
def _resolve_model(
|
||||
tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem
|
||||
) -> tuple[dict[str, Any] | None, ResolveError | None]:
|
||||
provider_configuration = provider_configurations.get(item.provider)
|
||||
if provider_configuration is None:
|
||||
return None, ({"message": f"provider '{item.provider}' not found"}, 404)
|
||||
|
||||
provider_schema = provider_configuration.provider.provider_credential_schema
|
||||
secret_variables = provider_configuration.extract_secret_variables(
|
||||
provider_schema.credential_form_schemas if provider_schema else []
|
||||
)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == item.credential_id,
|
||||
ProviderCredential.tenant_id == tenant_id,
|
||||
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
|
||||
)
|
||||
credential = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if credential is None or not credential.encrypted_config:
|
||||
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
|
||||
|
||||
try:
|
||||
values = json.loads(credential.encrypted_config)
|
||||
except JSONDecodeError:
|
||||
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
|
||||
if not isinstance(values, dict):
|
||||
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
|
||||
|
||||
for key in secret_variables:
|
||||
value = values.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"failed to resolve runtime model credential",
|
||||
extra={
|
||||
"credential_id": item.credential_id,
|
||||
"provider": item.provider,
|
||||
"tenant_id": tenant_id,
|
||||
"error": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
|
||||
|
||||
return values, None
|
||||
|
||||
|
||||
def _resolve_tool(
|
||||
tenant_id: str, item: InnerRuntimeCredentialResolveItem
|
||||
) -> tuple[dict[str, Any] | None, ResolveError | None]:
|
||||
try:
|
||||
provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"failed to load runtime tool provider",
|
||||
extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__},
|
||||
)
|
||||
return None, ({"message": f"tool provider '{item.provider}' not found"}, 404)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(BuiltinToolProvider).where(
|
||||
BuiltinToolProvider.id == item.credential_id,
|
||||
BuiltinToolProvider.tenant_id == tenant_id,
|
||||
)
|
||||
builtin_provider = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if builtin_provider is None:
|
||||
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
|
||||
|
||||
try:
|
||||
# Tool credentials are stored as a single encrypted dict; the secret
|
||||
# fields are decided by the schema bound to this credential type.
|
||||
provider_encrypter, _ = create_provider_encrypter(
|
||||
tenant_id=tenant_id,
|
||||
config=[
|
||||
schema.to_basic_provider_config()
|
||||
for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type)
|
||||
],
|
||||
cache=ToolProviderCredentialsCache(
|
||||
tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id
|
||||
),
|
||||
)
|
||||
values = dict(provider_encrypter.decrypt(builtin_provider.credentials))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"failed to resolve runtime tool credential",
|
||||
extra={
|
||||
"credential_id": item.credential_id,
|
||||
"provider": item.provider,
|
||||
"tenant_id": tenant_id,
|
||||
"error": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
|
||||
|
||||
return values, None
|
||||
@ -15114,7 +15114,6 @@ Default configuration for form inputs.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_dsl_version | string | | Yes |
|
||||
| branding | [BrandingModel](#brandingmodel) | | Yes |
|
||||
| enable_app_deploy | boolean | | Yes |
|
||||
| enable_change_email | boolean | | Yes |
|
||||
| enable_collaboration_mode | boolean | | Yes |
|
||||
| enable_creators_platform | boolean | | Yes |
|
||||
|
||||
@ -1325,7 +1325,6 @@ Returns Server-Sent Events stream.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_dsl_version | string | | Yes |
|
||||
| branding | [BrandingModel](#brandingmodel) | | Yes |
|
||||
| enable_app_deploy | boolean | | Yes |
|
||||
| enable_change_email | boolean | | Yes |
|
||||
| enable_collaboration_mode | boolean | | Yes |
|
||||
| enable_creators_platform | boolean | | Yes |
|
||||
|
||||
@ -161,7 +161,6 @@ class PluginManagerModel(FeatureResponseModel):
|
||||
|
||||
class SystemFeatureModel(FeatureResponseModel):
|
||||
app_dsl_version: str = ""
|
||||
enable_app_deploy: bool = False
|
||||
sso_enforced_for_signin: bool = False
|
||||
sso_enforced_for_signin_protocol: str = ""
|
||||
enable_marketplace: bool = False
|
||||
@ -254,7 +253,6 @@ class FeatureService:
|
||||
cls._fulfill_system_params_from_env(system_features)
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
system_features.enable_app_deploy = True
|
||||
system_features.branding.enabled = True
|
||||
system_features.webapp_auth.enabled = True
|
||||
system_features.enable_change_email = False
|
||||
|
||||
@ -291,7 +291,6 @@ class TestFeatureService:
|
||||
assert isinstance(result, SystemFeatureModel)
|
||||
|
||||
# Verify enterprise features
|
||||
assert result.enable_app_deploy is True
|
||||
assert result.branding.enabled is True
|
||||
assert result.webapp_auth.enabled is True
|
||||
assert result.enable_change_email is False
|
||||
@ -378,7 +377,6 @@ class TestFeatureService:
|
||||
# Ensure that data required for frontend rendering remains accessible.
|
||||
|
||||
# Branding should match the mock data
|
||||
assert result.enable_app_deploy is True
|
||||
assert result.branding.enabled is True
|
||||
assert result.branding.application_title == "Test Enterprise"
|
||||
assert result.branding.login_page_logo == "https://example.com/logo.png"
|
||||
@ -426,7 +424,6 @@ class TestFeatureService:
|
||||
assert isinstance(result, SystemFeatureModel)
|
||||
|
||||
# Verify basic configuration
|
||||
assert result.enable_app_deploy is False
|
||||
assert result.branding.enabled is False
|
||||
assert result.webapp_auth.enabled is False
|
||||
assert result.enable_change_email is True
|
||||
@ -628,7 +625,6 @@ class TestFeatureService:
|
||||
assert isinstance(result, SystemFeatureModel)
|
||||
|
||||
# Verify enterprise features are disabled
|
||||
assert result.enable_app_deploy is False
|
||||
assert result.branding.enabled is False
|
||||
assert result.webapp_auth.enabled is False
|
||||
assert result.enable_change_email is True
|
||||
|
||||
@ -1,206 +0,0 @@
|
||||
"""Unit tests for runtime credential inner API."""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.runtime_credentials import (
|
||||
EnterpriseRuntimeCredentialsResolve,
|
||||
InnerRuntimeCredentialsResolvePayload,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_credentials_payload_accepts_items():
|
||||
payload = InnerRuntimeCredentialsResolvePayload.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "credential-1",
|
||||
"provider": "langgenius/openai/openai",
|
||||
"kind": "model",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.tenant_id == "tenant-1"
|
||||
assert payload.credentials[0].provider == "langgenius/openai/openai"
|
||||
assert payload.credentials[0].kind == "model"
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
|
||||
@patch("controllers.inner_api.runtime_credentials.db")
|
||||
@patch("controllers.inner_api.runtime_credentials.Session")
|
||||
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
|
||||
def test_runtime_model_credentials_resolve_returns_decrypted_values(
|
||||
mock_provider_manager_factory,
|
||||
mock_session_cls,
|
||||
mock_db,
|
||||
mock_decrypt_token,
|
||||
app: Flask,
|
||||
):
|
||||
provider_configuration = MagicMock()
|
||||
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
|
||||
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
|
||||
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
|
||||
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.get.return_value = provider_configuration
|
||||
provider_manager = MagicMock()
|
||||
provider_manager.get_configurations.return_value = provider_configurations
|
||||
mock_provider_manager_factory.return_value = provider_manager
|
||||
|
||||
credential = MagicMock()
|
||||
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value = session
|
||||
session.__exit__.return_value = False
|
||||
session.execute.return_value.scalar_one_or_none.return_value = credential
|
||||
mock_session_cls.return_value = session
|
||||
mock_db.engine = MagicMock()
|
||||
mock_decrypt_token.return_value = "sk-test"
|
||||
|
||||
handler = EnterpriseRuntimeCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "credential-1",
|
||||
"provider": "langgenius/openai/openai",
|
||||
"kind": "model",
|
||||
}
|
||||
],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 200
|
||||
assert body["credentials"][0]["kind"] == "model"
|
||||
assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test"
|
||||
assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
|
||||
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
|
||||
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.get.return_value = None
|
||||
provider_manager = MagicMock()
|
||||
provider_manager.get_configurations.return_value = provider_configurations
|
||||
mock_provider_manager_factory.return_value = provider_manager
|
||||
|
||||
handler = EnterpriseRuntimeCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 404
|
||||
assert "provider" in body["message"]
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter")
|
||||
@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache")
|
||||
@patch("controllers.inner_api.runtime_credentials.db")
|
||||
@patch("controllers.inner_api.runtime_credentials.Session")
|
||||
@patch("controllers.inner_api.runtime_credentials.ToolManager")
|
||||
def test_runtime_tool_credentials_resolve_returns_decrypted_values(
|
||||
mock_tool_manager,
|
||||
mock_session_cls,
|
||||
mock_db,
|
||||
mock_cache_cls,
|
||||
mock_create_encrypter,
|
||||
app: Flask,
|
||||
):
|
||||
provider_controller = MagicMock()
|
||||
provider_controller.get_credentials_schema_by_type.return_value = []
|
||||
mock_tool_manager.get_builtin_provider.return_value = provider_controller
|
||||
|
||||
builtin_provider = MagicMock()
|
||||
builtin_provider.id = "credential-1"
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value = session
|
||||
session.__exit__.return_value = False
|
||||
session.execute.return_value.scalar_one_or_none.return_value = builtin_provider
|
||||
mock_session_cls.return_value = session
|
||||
mock_db.engine = MagicMock()
|
||||
|
||||
provider_encrypter = MagicMock()
|
||||
provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"}
|
||||
mock_create_encrypter.return_value = (provider_encrypter, MagicMock())
|
||||
|
||||
handler = EnterpriseRuntimeCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "credential-1",
|
||||
"provider": "langgenius/tavily/tavily",
|
||||
"kind": "tool",
|
||||
}
|
||||
],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 200
|
||||
assert body["credentials"][0]["kind"] == "tool"
|
||||
assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily"
|
||||
assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret"
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.db")
|
||||
@patch("controllers.inner_api.runtime_credentials.Session")
|
||||
@patch("controllers.inner_api.runtime_credentials.ToolManager")
|
||||
def test_runtime_tool_credentials_resolve_rejects_unknown_credential(
|
||||
mock_tool_manager,
|
||||
mock_session_cls,
|
||||
mock_db,
|
||||
app: Flask,
|
||||
):
|
||||
mock_tool_manager.get_builtin_provider.return_value = MagicMock()
|
||||
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value = session
|
||||
session.__exit__.return_value = False
|
||||
session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
mock_session_cls.return_value = session
|
||||
mock_db.engine = MagicMock()
|
||||
|
||||
handler = EnterpriseRuntimeCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 404
|
||||
assert "credential" in body["message"]
|
||||
|
||||
|
||||
def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask):
|
||||
handler = EnterpriseRuntimeCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 400
|
||||
assert "kind" in body["message"]
|
||||
@ -1,131 +1,202 @@
|
||||
import type { Key, Store } from '../store/store.js'
|
||||
import type { AccountContext } from './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 { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } 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')
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
})
|
||||
|
||||
it('parses a 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' },
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(parsed.token_storage).toBe('keychain')
|
||||
expect(parsed.tokens).toBeUndefined()
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
})
|
||||
|
||||
it('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('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
expect(() => RegistrySchema.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' },
|
||||
],
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
})
|
||||
expect(parsed.available_workspaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields on parse', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
future_field: 42,
|
||||
token_storage: 'file',
|
||||
})
|
||||
expect(parsed.current_host).toBe('cloud.dify.ai')
|
||||
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns undefined when nothing was saved', () => {
|
||||
expect(loadHosts()).toBeUndefined()
|
||||
it('returns an empty registry when nothing saved', () => {
|
||||
const reg = Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('round-trips a fully-populated bundle', () => {
|
||||
saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
it('round-trips a populated registry', () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
const loaded = Registry.load()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.scheme).toBe('https')
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.workspace?.id).toBe('ws-1')
|
||||
expect(loaded?.available_workspaces).toHaveLength(2)
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
expect(loaded?.token_id).toBe('tok_xyz')
|
||||
})
|
||||
|
||||
it('round-trips a file-mode bundle with bearer token', () => {
|
||||
saveHosts({
|
||||
current_host: 'self.example.com',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(loaded?.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('overwrites previous bundle on save', () => {
|
||||
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
|
||||
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('new.example.com')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('rejects invalid input at save time', () => {
|
||||
expect(() => saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'cloud',
|
||||
} as never)).toThrow()
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { Store } from '../store/store.js'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
import { getHostStore, tokenKey } from '../store/manager.js'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
@ -25,42 +27,152 @@ export const ExternalSubjectSchema = z.object({
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const TokensSchema = z.object({
|
||||
bearer: z.string(),
|
||||
})
|
||||
export type Tokens = z.infer<typeof TokensSchema>
|
||||
|
||||
export const HostsBundleSchema = z.object({
|
||||
current_host: z.string().default(''),
|
||||
scheme: z.string().optional(),
|
||||
account: AccountSchema.optional(),
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
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<typeof HostsBundleSchema>
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
|
||||
export function loadHosts(): HostsBundle | undefined {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return undefined
|
||||
return HostsBundleSchema.parse(raw)
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
}
|
||||
|
||||
export function saveHosts(bundle: HostsBundle): void {
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
getHostStore().setTyped(validated)
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
export function clearLocal(bundle: HostsBundle, store: Store): void {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
store.unset(tokenKey(bundle.current_host, accountId))
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static load(): Registry {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
const email = entry.current_account
|
||||
if (email === undefined || email === '')
|
||||
return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
this.save()
|
||||
}
|
||||
|
||||
save(): void {
|
||||
getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
getHostStore().rm()
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../cache/app-info.js'
|
||||
import type { Command } from '../../framework/command.js'
|
||||
import type { Store } from '../../store/store.js'
|
||||
import type { IOStreams } from '../../sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
|
||||
import { loadHosts } from '../../auth/hosts.js'
|
||||
import { notLoggedInError, Registry } from '../../auth/hosts.js'
|
||||
import { loadAppInfoCache } from '../../cache/app-info.js'
|
||||
import { loadNudgeStore } from '../../cache/nudge-store.js'
|
||||
import { getEnv } from '../../env/registry.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 { getTokenStore, tokenKey } from '../../store/manager.js'
|
||||
import { realStreams } from '../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../util/host.js'
|
||||
import { versionInfo } from '../../version/info.js'
|
||||
@ -19,7 +19,9 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
|
||||
import { resolveRetryAttempts } from './global-flags.js'
|
||||
|
||||
export type AuthedContext = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -37,28 +39,30 @@ export async function buildAuthedContext(
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const bundle = loadHosts()
|
||||
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: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({
|
||||
flag: opts.retryFlag,
|
||||
env: getEnv,
|
||||
})
|
||||
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createClient({ host, bearer, retryAttempts })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { bundle, http, host, io, cache }
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
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 { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { Registry } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../../store/dir.js'
|
||||
import { tokenKey } from '../../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../../sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
@ -30,20 +30,21 @@ class MemStore implements Store {
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
reg.setAccount(email)
|
||||
const active = reg.resolveActive()!
|
||||
return { reg, active }
|
||||
}
|
||||
|
||||
describe('runDevicesList', () => {
|
||||
@ -58,7 +59,7 @@ describe('runDevicesList', () => {
|
||||
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 })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('DEVICE')
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
@ -71,20 +72,12 @@ describe('runDevicesList', () => {
|
||||
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 })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
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', () => {
|
||||
@ -109,12 +102,12 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact device_label: revokes one + leaves local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -122,30 +115,30 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact id: revokes one', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: unique match revokes', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: ambiguous throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -153,10 +146,10 @@ describe('runDevicesRevoke', () => {
|
||||
it('no match throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -164,31 +157,33 @@ describe('runDevicesRevoke', () => {
|
||||
it('--all: revokes everything except current', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
it('revoking current id clears local creds', async () => {
|
||||
it('revoking current session clears token and removes context from registry', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
const saved = Registry.load()
|
||||
expect(saved?.hosts[mock.url]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { ActiveContext, Registry } from '../../../../auth/hosts.js'
|
||||
import type { Store } from '../../../../store/store.js'
|
||||
import type { IOStreams } from '../../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
import { getTokenStore } from '../../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
|
||||
import { runWithSpinner } from '../../../../sys/io/spinner.js'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly tokenId: string
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
@ -23,7 +21,6 @@ export type DevicesListOptions = {
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -38,7 +35,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
@ -72,10 +69,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly http: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -83,7 +80,6 @@ export type DevicesRevokeOptions = {
|
||||
|
||||
export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void> {
|
||||
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,
|
||||
@ -94,7 +90,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
@ -103,25 +99,12 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit) {
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(b, tokens)
|
||||
}
|
||||
if (selfHit)
|
||||
opts.reg.forget(opts.active, 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
|
||||
|
||||
@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
tokenId: ctx.active.ctx.token_id ?? '',
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
|
||||
@ -26,7 +26,9 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runDevicesRevoke({
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
store: ctx.store,
|
||||
http: ctx.http,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
|
||||
@ -59,7 +59,7 @@ describe('runLogin', () => {
|
||||
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
const reg = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -70,16 +70,17 @@ describe('runLogin', () => {
|
||||
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 = store.get(tokenKey(bundle.current_host, 'acct-1'))
|
||||
expect(stored).toBe('dfoa_test')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
expect(hostsRaw).toContain('tester@dify.ai')
|
||||
expect(hostsRaw).not.toContain('dfoa_test')
|
||||
expect(hostsRaw).not.toContain('bearer')
|
||||
|
||||
expect(io.outBuf()).toContain('Logged in to')
|
||||
expect(io.outBuf()).toContain('tester@dify.ai')
|
||||
@ -91,7 +92,7 @@ describe('runLogin', () => {
|
||||
mock.setScenario('sso')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
const reg = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -102,12 +103,11 @@ describe('runLogin', () => {
|
||||
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')
|
||||
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
|
||||
expect(stored).toBe('dfoe_test')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
@ -148,6 +148,24 @@ describe('runLogin', () => {
|
||||
})).rejects.toThrow(/expired/)
|
||||
})
|
||||
|
||||
it('rejects login when the account has no email', async () => {
|
||||
mock.setScenario('no-email')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
insecure: true,
|
||||
deviceLabel: 'difyctl on test',
|
||||
api: new DeviceFlowApi(createClient({ host: mock.url })),
|
||||
store: { store, mode: 'file' },
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})).rejects.toThrow(/no email/i)
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects http:// host without --insecure', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
|
||||
import type { StorageMode, Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
|
||||
@ -7,10 +7,13 @@ 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 { Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { startSpinner } from '../../../sys/io/spinner.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'
|
||||
@ -28,7 +31,7 @@ export type LoginOptions = {
|
||||
readonly clock?: Clock
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const insecure = opts.insecure ?? false
|
||||
|
||||
@ -56,22 +59,44 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
|
||||
}
|
||||
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
|
||||
let success: PollSuccess
|
||||
try {
|
||||
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
}
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
|
||||
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
|
||||
saveHosts(bundle)
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.save()
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return bundle
|
||||
return reg
|
||||
}
|
||||
|
||||
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
|
||||
let raw = opts.host?.trim() ?? ''
|
||||
if (raw === '')
|
||||
if (raw === '') {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--host is required (no TTY)',
|
||||
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
|
||||
})
|
||||
}
|
||||
raw = await promptHost(opts.io)
|
||||
}
|
||||
return resolveHost({ raw, insecure })
|
||||
}
|
||||
|
||||
@ -122,50 +147,43 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
|
||||
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(':', '')
|
||||
function accountEmail(s: PollSuccess): string {
|
||||
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
|
||||
if (email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'account has no email; cannot store credential',
|
||||
hint: 'this Dify instance returned no email for the signed-in subject',
|
||||
})
|
||||
}
|
||||
catch { /* keep undefined */ }
|
||||
return email
|
||||
}
|
||||
|
||||
const bundle: HostsBundle = {
|
||||
current_host: display,
|
||||
scheme,
|
||||
token_storage: mode,
|
||||
function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const ctx: AccountContext = {
|
||||
account: s.account
|
||||
? { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
: { id: '', email: '', name: '' },
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
}
|
||||
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
bundle.workspace = def
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
}))
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return bundle
|
||||
return ctx
|
||||
}
|
||||
|
||||
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'
|
||||
function applyScheme(reg: Registry, display: string, host: string): void {
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
reg.setScheme(display, u.protocol.replace(':', ''))
|
||||
}
|
||||
catch { /* keep scheme unset */ }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../../util/host.js'
|
||||
@ -16,21 +17,21 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const bundle = loadHosts()
|
||||
const io = realStreams()
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
|
||||
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,
|
||||
})
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
if (bearer !== '') {
|
||||
http = createClient({ host: hostWithScheme(active.host, active.scheme), bearer, retryAttempts: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, bundle, http }),
|
||||
() => runLogout({ io, reg, http }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,145 +1,64 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
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 { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { tokenKey } from '../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
}
|
||||
}
|
||||
|
||||
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' },
|
||||
],
|
||||
}
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy: revokes server side, clears local store + hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
function seed(store: MemStore) {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ 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('')
|
||||
seed(store)
|
||||
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
})
|
||||
|
||||
it('not-logged-in: throws BaseError', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogout({ 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({ 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)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ 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' }
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ 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)
|
||||
saveHosts(bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/i)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,54 +1,46 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore } from '../../../store/manager.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
|
||||
export type LogoutOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
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\'',
|
||||
})
|
||||
}
|
||||
const reg = opts.reg
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
|
||||
let revokeWarning = ''
|
||||
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
try {
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
await sessions.revokeSelf()
|
||||
await new AccountSessionsClient(opts.http).revokeSelf()
|
||||
}
|
||||
catch (err) {
|
||||
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(bundle, tokens)
|
||||
reg.forget(active, store)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -20,7 +20,7 @@ export default class Status extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const bundle = loadHosts()
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
const reg = Registry.load()
|
||||
await runStatus({ io: realStreams(), reg, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,65 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runStatus } from './status.js'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
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' },
|
||||
],
|
||||
}
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'tester@dify.ai',
|
||||
accounts: {
|
||||
'tester@dify.ai': {
|
||||
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_id: 'tok-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ssoBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-sso-1',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'sso@dify.ai',
|
||||
accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { id: '', email: '', name: '' },
|
||||
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/)
|
||||
await expect(runStatus({ io, reg: Registry.empty() })).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/)
|
||||
await expect(runStatus({ io, reg: Registry.empty(), 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() })
|
||||
await runStatus({ io, reg: accountReg() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
|
||||
expect(out).toContain('Workspace: Default')
|
||||
@ -52,7 +68,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account verbose: shows ids + storage + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), verbose: true })
|
||||
await runStatus({ io, reg: accountReg(), verbose: true })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('Account:')
|
||||
@ -60,11 +76,12 @@ describe('runStatus', () => {
|
||||
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
|
||||
expect(out).toContain('Available: 2 workspaces')
|
||||
expect(out).toContain('Storage: keychain')
|
||||
expect(out).toContain('Contexts:')
|
||||
})
|
||||
|
||||
it('sso: human compact mentions issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle() })
|
||||
await runStatus({ io, reg: ssoReg() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
|
||||
expect(out).toContain('apps:run')
|
||||
@ -72,7 +89,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account json: matches schema with workspace + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), json: true })
|
||||
await runStatus({ io, reg: accountReg(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.host).toBe('cloud.dify.ai')
|
||||
expect(parsed.logged_in).toBe(true)
|
||||
@ -84,7 +101,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle(), json: true })
|
||||
await runStatus({ io, reg: ssoReg(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.subject_type).toBe('external_sso')
|
||||
expect(parsed.subject_email).toBe('sso@dify.ai')
|
||||
|
||||
@ -1,91 +1,94 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AccountContext, Registry } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type StatusOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly verbose?: boolean
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runStatus(opts: StatusOptions): Promise<void> {
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
if (opts.json === true) {
|
||||
const reg = opts.reg
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined) {
|
||||
if (opts.json === true)
|
||||
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
|
||||
}
|
||||
else {
|
||||
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`)
|
||||
opts.io.out.write(`${renderJson(active.host, active.ctx, reg.token_storage)}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
|
||||
opts.io.out.write(renderHuman(active.host, active.ctx, reg.token_storage, opts.verbose ?? false))
|
||||
if (opts.verbose === true)
|
||||
opts.io.out.write(renderContexts(reg))
|
||||
}
|
||||
|
||||
function renderHuman(b: HostsBundle, verbose: boolean): string {
|
||||
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
|
||||
const lines: string[] = []
|
||||
const sub = ctx.external_subject
|
||||
if (!verbose) {
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
if (sub !== undefined) {
|
||||
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)`)
|
||||
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${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(`Logged in to ${host} as ${ctx.account.email} (${ctx.account.name})`)
|
||||
if (ctx.workspace?.name !== undefined && ctx.workspace.name !== '')
|
||||
lines.push(` Workspace: ${ctx.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)
|
||||
if (sub !== undefined) {
|
||||
lines.push(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}`)
|
||||
lines.push(` Storage: ${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(host)
|
||||
lines.push(` Account: ${ctx.account.email} (${ctx.account.name}, ${ctx.account.id ?? ''})`)
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name} (${ctx.workspace.id}, role: ${ctx.workspace.role})`)
|
||||
lines.push(` Available: ${ctx.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(' Session: Dify account — full access (scope: full)')
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
lines.push(` Storage: ${storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(b: HostsBundle): string {
|
||||
const out: Record<string, unknown> = {
|
||||
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 }
|
||||
function renderContexts(reg: Registry): string {
|
||||
const lines = ['Contexts:']
|
||||
for (const [host, entry] of Object.entries(reg.hosts)) {
|
||||
for (const email of Object.keys(entry.accounts)) {
|
||||
const isActive = reg.current_host === host && entry.current_account === email
|
||||
lines.push(` ${isActive ? '*' : ' '} ${host} ${email}`)
|
||||
}
|
||||
out.available_workspaces_count = b.available_workspaces?.length ?? 0
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(host: string, ctx: AccountContext, storage: string): string {
|
||||
const out: Record<string, unknown> = { host, logged_in: true, storage }
|
||||
if (ctx.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = ctx.external_subject.email
|
||||
out.subject_issuer = ctx.external_subject.issuer
|
||||
}
|
||||
else {
|
||||
out.account = { id: ctx.account.id ?? '', email: ctx.account.email, name: ctx.account.name }
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
out.workspace = { id: ctx.workspace.id, name: ctx.workspace.name, role: ctx.workspace.role }
|
||||
out.available_workspaces_count = ctx.available_workspaces?.length ?? 0
|
||||
}
|
||||
return JSON.stringify(out, null, 2)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const bundle = loadHosts()
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
const reg = Registry.load()
|
||||
await runWhoami({ io: realStreams(), reg, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,68 +1,82 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runWhoami } from './whoami.js'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'keychain',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
}
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
|
||||
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { email: 'sso@dify.ai', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('runWhoami', () => {
|
||||
it('logged-out: throws NotLoggedIn', async () => {
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
|
||||
})
|
||||
|
||||
it('prints email + name for an account', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toContain('a@b.c')
|
||||
expect(io.outBuf()).toContain('Ann')
|
||||
})
|
||||
|
||||
it('account human: emits "email (name)"', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, bundle: accountBundle() })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
|
||||
})
|
||||
|
||||
it('account human, no name: emits email only', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = accountBundle()
|
||||
b.account!.name = ''
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai\n')
|
||||
const reg = accountReg()
|
||||
reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = ''
|
||||
await runWhoami({ io, reg })
|
||||
expect(io.outBuf()).toBe('a@b.c\n')
|
||||
})
|
||||
|
||||
it('emits JSON when --json', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' })
|
||||
})
|
||||
|
||||
it('account json: emits {id, email, name}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, bundle: accountBundle(), json: true })
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
id: 'acct-1',
|
||||
email: 'tester@dify.ai',
|
||||
name: 'Test Tester',
|
||||
email: 'a@b.c',
|
||||
name: 'Ann',
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
await runWhoami({ io, reg: ssoReg() })
|
||||
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
|
||||
})
|
||||
|
||||
it('sso json: emits {subject_type, email, issuer}', async () => {
|
||||
const io = bufferStreams()
|
||||
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 })
|
||||
await runWhoami({ io, reg: ssoReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
subject_type: 'external_sso',
|
||||
email: 'sso@dify.ai',
|
||||
|
||||
@ -1,46 +1,31 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type WhoamiOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
|
||||
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\'',
|
||||
})
|
||||
}
|
||||
const active = opts.reg.requireActive()
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = active.ctx.external_subject
|
||||
if (sub !== 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`)
|
||||
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.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: '' }
|
||||
const acc = active.ctx.account
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(acc.name !== ''
|
||||
? `${acc.email} (${acc.name})\n`
|
||||
: `${acc.email}\n`)
|
||||
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'inviter@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +36,7 @@ describe('runCreateMember', () => {
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -60,7 +61,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -76,7 +77,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -91,7 +92,7 @@ describe('runCreateMember', () => {
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runCreateMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
|
||||
@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +28,7 @@ describe('runDeleteMember', () => {
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -45,7 +46,7 @@ describe('runDeleteMember', () => {
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -60,7 +61,7 @@ describe('runDeleteMember', () => {
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -51,7 +51,7 @@ export async function runDeleteMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
|
||||
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
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 },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -12,17 +12,18 @@ import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
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' },
|
||||
],
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +50,7 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
|
||||
}
|
||||
@ -92,13 +93,13 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ active: active(), 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 },
|
||||
{ active: active(), 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 ?? '')
|
||||
@ -112,7 +113,7 @@ describe('runDescribeApp', () => {
|
||||
await expect(runDescribeApp(
|
||||
{ appId: 'nope' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
|
||||
}
|
||||
|
||||
export type DescribeAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io?: IOStreams
|
||||
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
format,
|
||||
data: result.data,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } 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'
|
||||
@ -7,17 +7,18 @@ 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',
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
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', () => {
|
||||
@ -36,7 +37,7 @@ describe('runGetApp', () => {
|
||||
}
|
||||
|
||||
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
|
||||
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
|
||||
const result = await runGetApp(opts, { active: baseActive, http: http() })
|
||||
return stringifyOutput(table({
|
||||
format: opts.format ?? '',
|
||||
data: result.data,
|
||||
@ -134,7 +135,11 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
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/)
|
||||
const minimal: ActiveContext = {
|
||||
host: 'h',
|
||||
email: 'x@x.com',
|
||||
ctx: { account: { email: 'x@x.com', name: 'X' } },
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppsClient } from '../../../api/apps.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
@ -24,7 +24,7 @@ export type GetAppOptions = {
|
||||
}
|
||||
|
||||
export type GetAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
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 wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, 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 })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
@ -111,12 +111,13 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceNameForId(b: HostsBundle, id: string): string {
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
if (b.workspace?.id === id)
|
||||
return b.workspace.name
|
||||
for (const w of b.available_workspaces ?? []) {
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +38,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -54,7 +55,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -69,7 +70,7 @@ describe('runGetMember', () => {
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -78,14 +79,20 @@ describe('runGetMember', () => {
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
it('marks no row when active context has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const a: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: '', email: '', name: '' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
},
|
||||
}
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: b,
|
||||
active: a,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -96,16 +103,16 @@ describe('runGetMember', () => {
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
const noWs: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } },
|
||||
}
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
active: noWs,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
@ -132,7 +139,7 @@ describe('MemberListOutput shape', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
|
||||
@ -16,7 +16,7 @@ export type GetMemberOptions = {
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -39,7 +39,7 @@ export async function runGetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -50,7 +50,7 @@ export async function runGetMember(
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const callerId = deps.active.ctx.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand {
|
||||
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 })
|
||||
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
if (result.kind === 'empty')
|
||||
return raw(result.message)
|
||||
return table({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } 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'
|
||||
@ -7,17 +7,18 @@ 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',
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
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', () => {
|
||||
@ -35,8 +36,8 @@ describe('runGetWorkspace', () => {
|
||||
return createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
}
|
||||
|
||||
async function render(format = '', bundle = baseBundle): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { bundle, http: http() })
|
||||
async function render(format = '', activeCtx = baseActive): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
|
||||
if (result.kind === 'empty')
|
||||
return result.message
|
||||
return stringifyOutput(table({
|
||||
@ -75,8 +76,8 @@ describe('runGetWorkspace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to bundle workspace.id when server current=false', async () => {
|
||||
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
|
||||
it('falls back to active context workspace.id when server current=false', async () => {
|
||||
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, 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'))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type GetWorkspaceDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
|
||||
)
|
||||
if (env.workspaces.length === 0)
|
||||
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
|
||||
const currentId = deps.bundle.workspace?.id ?? ''
|
||||
const currentId = deps.active.ctx.workspace?.id ?? ''
|
||||
return {
|
||||
kind: 'output',
|
||||
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(
|
||||
|
||||
@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { RunContext } from '../../run/app/_strategies/index.js'
|
||||
@ -30,7 +30,7 @@ export type ResumeAppOptions = {
|
||||
}
|
||||
|
||||
export type ResumeAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -78,7 +78,7 @@ async function resolveInputs(
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -13,17 +13,18 @@ import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { resumeApp } from '../../resume/app/run.js'
|
||||
import { runApp } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
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' },
|
||||
],
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +52,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), 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')
|
||||
@ -62,7 +63,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
|
||||
})
|
||||
|
||||
@ -71,7 +72,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -81,7 +82,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), 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')
|
||||
@ -92,7 +93,7 @@ describe('runApp', () => {
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
@ -101,7 +102,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'nope', message: 'hi' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -114,7 +115,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('echo: ')
|
||||
expect(io.outBuf()).toContain('hi')
|
||||
@ -126,7 +127,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), 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')
|
||||
@ -139,7 +140,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), 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')
|
||||
@ -150,7 +151,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('go')
|
||||
expect(io.errBuf()).toContain('thought:')
|
||||
@ -161,7 +162,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), 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')
|
||||
@ -174,7 +175,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
})
|
||||
|
||||
@ -186,7 +187,7 @@ describe('runApp', () => {
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -198,7 +199,7 @@ describe('runApp', () => {
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/must be a JSON object/)
|
||||
})
|
||||
|
||||
@ -207,7 +208,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -219,7 +220,7 @@ describe('runApp', () => {
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
|
||||
@ -231,7 +232,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -260,7 +261,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -284,7 +285,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -295,7 +296,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -306,7 +307,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
// stream mode for workflow: node_started → "→ <title>" on stderr
|
||||
expect(io.errBuf()).toContain('After Resume')
|
||||
@ -317,7 +318,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(0)
|
||||
@ -338,7 +339,7 @@ describe('runApp', () => {
|
||||
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 },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(1)
|
||||
@ -355,7 +356,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), 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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -32,7 +32,7 @@ export type RunAppOptions = {
|
||||
}
|
||||
|
||||
export type RunAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -80,7 +80,7 @@ async function resolveInputs(
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
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])
|
||||
|
||||
@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +28,7 @@ describe('runSetMember', () => {
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -46,7 +47,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -62,7 +63,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -76,7 +77,7 @@ describe('runSetMember', () => {
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type SetMemberOptions = {
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runSetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
|
||||
@ -26,6 +26,8 @@ import HelpExternal from './help/external/index.js'
|
||||
import ResumeApp from './resume/app/index.js'
|
||||
import RunApp from './run/app/index.js'
|
||||
import SetMember from './set/member/index.js'
|
||||
import UseAccount from './use/account/index.js'
|
||||
import UseHost from './use/host/index.js'
|
||||
import UseWorkspace from './use/workspace/index.js'
|
||||
import Version from './version/index.js'
|
||||
|
||||
@ -104,6 +106,8 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
account: { command: UseAccount, subcommands: {} },
|
||||
host: { command: UseHost, subcommands: {} },
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
|
||||
22
cli/src/commands/use/account/index.ts
Normal file
22
cli/src/commands/use/account/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
export default class UseAccount extends DifyCommand {
|
||||
static override description = 'Switch the active account on the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use account',
|
||||
'<%= config.bin %> use account --email bob@corp.com',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
email: Flags.string({ description: 'account email to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseAccount, argv)
|
||||
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
|
||||
}
|
||||
}
|
||||
63
cli/src/commands/use/account/use-account.test.ts
Normal file
63
cli/src/commands/use/account/use-account.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { Key, Store } from '../../../store/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 } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseAccount', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
it('errors when the account has no stored token', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
|
||||
.rejects
|
||||
.toThrow(/log in|no credential/i)
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when email omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
|
||||
})
|
||||
})
|
||||
76
cli/src/commands/use/account/use-account.ts
Normal file
76
cli/src/commands/use/account/use-account.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { HostEntry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
|
||||
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
|
||||
|
||||
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
if (reg.current_host === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
const host = reg.current_host
|
||||
const entry = reg.hosts[host]
|
||||
if (entry === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
|
||||
const emails = Object.keys(entry.accounts)
|
||||
const target = opts.email ?? await pickAccount(opts, entry, host)
|
||||
if (!emails.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
hint: `run 'difyctl auth login --host ${host}'`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setAccount(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
|
||||
const emails = Object.keys(entry.accounts)
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
|
||||
email,
|
||||
name: ctx.account.name,
|
||||
sso: ctx.external_subject !== undefined,
|
||||
active: entry.current_account === email,
|
||||
}))
|
||||
const picked = await selectFromList<AccountChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: `Select an account on ${host}`,
|
||||
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
|
||||
})
|
||||
return picked.email
|
||||
}
|
||||
22
cli/src/commands/use/host/index.ts
Normal file
22
cli/src/commands/use/host/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
export default class UseHost extends DifyCommand {
|
||||
static override description = 'Switch the active Dify host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use host',
|
||||
'<%= config.bin %> use host --domain cloud.dify.ai',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
domain: Flags.string({ description: 'domain to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseHost, argv)
|
||||
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
|
||||
}
|
||||
}
|
||||
50
cli/src/commands/use/host/use-host.test.ts
Normal file
50
cli/src/commands/use/host/use-host.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
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 { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
describe('runUseHost', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_host when host is valid', async () => {
|
||||
await runUseHost({ io: bufferStreams(), host: 'h2' })
|
||||
expect(Registry.load().current_host).toBe('h2')
|
||||
})
|
||||
|
||||
it('errors when host is unknown, listing valid hosts', async () => {
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when host omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
|
||||
})
|
||||
|
||||
it('errors when no hosts exist', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
|
||||
})
|
||||
})
|
||||
54
cli/src/commands/use/host/use-host.ts
Normal file
54
cli/src/commands/use/host/use-host.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseHostOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly host: string | undefined
|
||||
}
|
||||
|
||||
type HostChoice = { host: string, accounts: number, active: boolean }
|
||||
|
||||
export async function runUseHost(opts: UseHostOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
const hosts = Object.keys(reg.hosts)
|
||||
if (hosts.length === 0)
|
||||
throw notLoggedInError()
|
||||
|
||||
const target = opts.host ?? await pickHost(opts, reg, hosts)
|
||||
if (!hosts.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setHost(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: HostChoice[] = hosts.map(h => ({
|
||||
host: h,
|
||||
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
|
||||
active: reg.current_host === h,
|
||||
}))
|
||||
const picked = await selectFromList<HostChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: 'Select a host',
|
||||
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
|
||||
})
|
||||
return picked.host
|
||||
}
|
||||
@ -22,7 +22,8 @@ export default class UseWorkspace extends DifyCommand {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
bundle: ctx.bundle,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
|
||||
@ -3,28 +3,36 @@ import type {
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } 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, vi } from 'vitest'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
}
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
return reg
|
||||
}
|
||||
|
||||
function makeActive(reg: Registry): ActiveContext {
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
throw new Error('resolveActive returned undefined in test setup')
|
||||
return active
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
@ -68,14 +76,16 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -84,40 +94,65 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -127,7 +162,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -136,16 +172,18 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = loadHosts()
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -155,7 +193,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -163,14 +202,15 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = loadHosts()
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -192,7 +232,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
@ -13,7 +12,8 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -31,12 +31,12 @@ export type UseWorkspaceDeps = {
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<HostsBundle> {
|
||||
): Promise<Registry> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
@ -60,16 +60,13 @@ export async function runUseWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
}
|
||||
saveHosts(next)
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return next
|
||||
return deps.reg
|
||||
}
|
||||
|
||||
95
cli/src/sys/io/select.test.ts
Normal file
95
cli/src/sys/io/select.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectFromList } from './select'
|
||||
import { bufferStreams } from './streams'
|
||||
|
||||
type Row = { id: string, label: string }
|
||||
const rows: Row[] = [
|
||||
{ id: '1', label: 'alpha' },
|
||||
{ id: '2', label: 'beta' },
|
||||
{ id: '3', label: 'gamma' },
|
||||
]
|
||||
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
|
||||
type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown }
|
||||
|
||||
function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn {
|
||||
const stream = new PassThrough() as unknown as FakeTTYIn
|
||||
stream.isTTY = true
|
||||
stream.isRaw = false
|
||||
stream.setRawMode = (mode: boolean): unknown => {
|
||||
if (opts.failRawMode === true && mode)
|
||||
throw new Error('raw mode unavailable')
|
||||
stream.isRaw = mode
|
||||
return stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> {
|
||||
const io = bufferStreams()
|
||||
;(io as { in: NodeJS.ReadableStream }).in = input
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = true
|
||||
return io
|
||||
}
|
||||
|
||||
describe('selectFromList (non-TTY numbered fallback)', () => {
|
||||
it('returns the item matching the typed number', async () => {
|
||||
const io = bufferStreams('2\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label })
|
||||
expect(picked.id).toBe('2')
|
||||
expect(io.errBuf()).toContain('1) alpha')
|
||||
expect(io.errBuf()).toContain('Pick one')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range selection', async () => {
|
||||
const io = bufferStreams('9\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/invalid selection/i)
|
||||
})
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const io = bufferStreams('1\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label }))
|
||||
.rejects
|
||||
.toThrow(/nothing to select/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFromList (interactive TTY picker)', () => {
|
||||
it('moves with arrow keys and resolves on enter, restoring raw mode', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B[B')
|
||||
input.write('\r')
|
||||
const picked = await pick
|
||||
expect(picked.id).toBe('2')
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
|
||||
it('cancels on escape', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B')
|
||||
await expect(pick).rejects.toThrow(/cancelled/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects and restores the terminal when raw-mode setup fails', async () => {
|
||||
const input = ttyInput({ failRawMode: true })
|
||||
const io = ttyStreams(input)
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/raw mode unavailable/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
})
|
||||
153
cli/src/sys/io/select.ts
Normal file
153
cli/src/sys/io/select.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import type { Key } from 'node:readline'
|
||||
import type { IOStreams } from './streams'
|
||||
import * as readline from 'node:readline'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from './color.js'
|
||||
|
||||
export type SelectOptions<T> = {
|
||||
readonly io: IOStreams
|
||||
readonly items: readonly T[]
|
||||
readonly header: string
|
||||
/** Single rich line shown per option. */
|
||||
readonly render: (item: T) => string
|
||||
/** Optional second line shown only for the focused option in the TTY picker. */
|
||||
readonly describe?: (item: T) => string
|
||||
}
|
||||
|
||||
const HIDE_CURSOR = '\x1B[?25l'
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
const CLEAR_DOWN = '\x1B[0J'
|
||||
const cursorUp = (n: number): string => `\x1B[${n}A`
|
||||
|
||||
export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
if (opts.items.length === 0)
|
||||
throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' })
|
||||
return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-key picker built on Node's readline keypress events — no third-party
|
||||
* prompt library, so it bundles cleanly into the compiled binary. Renders to
|
||||
* the err stream, redrawing in place on each keystroke and erasing itself on
|
||||
* exit so the caller's own output starts on a clean row.
|
||||
*/
|
||||
async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
const input = opts.io.in as NodeJS.ReadStream
|
||||
const out = opts.io.err
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const count = opts.items.length
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = 0
|
||||
let rendered = 0
|
||||
|
||||
const frame = (): readonly string[] => {
|
||||
const lines = [opts.header]
|
||||
opts.items.forEach((item, i) => {
|
||||
const focused = i === active
|
||||
const pointer = focused ? cs.cyan('❯') : ' '
|
||||
const label = focused ? cs.bold(opts.render(item)) : opts.render(item)
|
||||
lines.push(`${pointer} ${label}`)
|
||||
})
|
||||
const desc = opts.describe?.(opts.items[active] as T)
|
||||
if (desc !== undefined && desc !== '')
|
||||
lines.push(cs.dim(` ${desc}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
const render = (): void => {
|
||||
if (rendered > 0)
|
||||
out.write(cursorUp(rendered))
|
||||
const lines = frame()
|
||||
out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`)
|
||||
rendered = lines.length
|
||||
}
|
||||
|
||||
const wasRaw = input.isTTY ? input.isRaw : false
|
||||
const cleanup = (): void => {
|
||||
input.off('keypress', onKey)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(wasRaw)
|
||||
input.pause()
|
||||
if (rendered > 0)
|
||||
out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`)
|
||||
out.write(SHOW_CURSOR)
|
||||
}
|
||||
|
||||
function onKey(_str: string | undefined, key: Key): void {
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
return
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'up':
|
||||
case 'k':
|
||||
active = (active - 1 + count) % count
|
||||
render()
|
||||
break
|
||||
case 'down':
|
||||
case 'j':
|
||||
active = (active + 1) % count
|
||||
render()
|
||||
break
|
||||
case 'return':
|
||||
case 'enter': {
|
||||
const chosen = opts.items[active]
|
||||
cleanup()
|
||||
if (chosen === undefined)
|
||||
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }))
|
||||
else
|
||||
resolve(chosen)
|
||||
break
|
||||
}
|
||||
case 'escape':
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
readline.emitKeypressEvents(input)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(true)
|
||||
out.write(HIDE_CURSOR)
|
||||
input.on('keypress', onKey)
|
||||
input.resume()
|
||||
render()
|
||||
}
|
||||
catch (err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelled(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' })
|
||||
}
|
||||
|
||||
async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
opts.io.err.write(`${opts.header}\n`)
|
||||
opts.items.forEach((item, idx) => {
|
||||
opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`)
|
||||
})
|
||||
opts.io.err.write('Enter number: ')
|
||||
|
||||
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
|
||||
try {
|
||||
const line: string = await new Promise(resolve => rl.once('line', resolve))
|
||||
const n = Number(line.trim())
|
||||
const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined
|
||||
if (chosen === undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` })
|
||||
return chosen
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,30 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { platform, 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 { Registry } from '../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { arch } from '../sys/index.js'
|
||||
import { runVersionProbe } from './probe.js'
|
||||
|
||||
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
|
||||
function active(overrides: Partial<ActiveContext> = {}): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'test@dify.ai',
|
||||
ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } },
|
||||
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(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -38,7 +38,7 @@ describe('runVersionProbe', () => {
|
||||
let observed: string | undefined
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }),
|
||||
loadActive: async () => active(),
|
||||
probe: async (endpoint) => {
|
||||
observed = endpoint
|
||||
return { version: '1.6.4', edition: 'CLOUD' }
|
||||
@ -49,10 +49,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns no-host + unknown compat when bundle is missing', async () => {
|
||||
it('returns no-host + unknown compat when active context is missing', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -61,10 +61,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('no host')
|
||||
})
|
||||
|
||||
it('returns no-host when bundle has empty current_host', async () => {
|
||||
it('returns no-host when active context has empty host', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ current_host: '' }),
|
||||
loadActive: async () => active({ host: '' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -72,10 +72,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => {
|
||||
it('distinguishes loadActive disk failure from no-host configured in the detail', async () => {
|
||||
const errReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => { throw new Error('disk-explode') },
|
||||
loadActive: async () => { throw new Error('disk-explode') },
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(errReport.server.reachable).toBe(false)
|
||||
@ -84,7 +84,7 @@ describe('runVersionProbe', () => {
|
||||
|
||||
const noHostReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(noHostReport.compat.detail).toContain('no host')
|
||||
@ -94,7 +94,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns compatible report when server is reachable and in range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -108,7 +108,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -119,7 +119,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -130,7 +130,7 @@ describe('runVersionProbe', () => {
|
||||
it('treats probe rejection as unreachable + unknown compat', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => { throw new Error('timeout') },
|
||||
})
|
||||
|
||||
@ -141,10 +141,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('builds endpoint using bundle scheme when host has no scheme', async () => {
|
||||
it('builds endpoint using active scheme when host has no scheme', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }),
|
||||
loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -161,12 +161,12 @@ describe('runVersionProbe', () => {
|
||||
const prevConfig = process.env[ENV_CONFIG_DIR]
|
||||
try {
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
saveHosts({
|
||||
current_host: url.host,
|
||||
scheme: url.protocol.replace(':', ''),
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } })
|
||||
reg.setHost(url.host)
|
||||
reg.setAccount('test@dify.ai')
|
||||
reg.setScheme(url.host, url.protocol.replace(':', ''))
|
||||
reg.save()
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
|
||||
const report = await runVersionProbe({ skipServer: false })
|
||||
@ -190,7 +190,7 @@ describe('runVersionProbe', () => {
|
||||
it('always includes client metadata in the report', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } 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 { Registry } from '../auth/hosts.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { arch, platform } from '../sys/index.js'
|
||||
import { hostWithScheme } from '../util/host.js'
|
||||
@ -43,11 +43,13 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse>
|
||||
|
||||
export type RunVersionProbeOptions = {
|
||||
readonly skipServer: boolean
|
||||
readonly loadBundle?: () => Promise<HostsBundle | undefined>
|
||||
readonly loadActive?: () => Promise<ActiveContext | undefined>
|
||||
readonly probe?: MetaProbe
|
||||
}
|
||||
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
|
||||
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
|
||||
return Registry.load().resolveActive()
|
||||
}
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
@ -89,19 +91,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const loadBundle = opts.loadBundle ?? defaultLoadBundle
|
||||
const loadActive = opts.loadActive ?? defaultLoadActive
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
|
||||
let bundle: HostsBundle | undefined
|
||||
let active: ActiveContext | undefined
|
||||
let loadFailed = false
|
||||
try {
|
||||
bundle = await loadBundle()
|
||||
active = await loadActive()
|
||||
}
|
||||
catch {
|
||||
loadFailed = true
|
||||
}
|
||||
|
||||
if (bundle === undefined || bundle.current_host === '') {
|
||||
if (active === undefined || active.host === '') {
|
||||
const detail = loadFailed ? 'hosts file unreadable' : 'no host configured'
|
||||
return {
|
||||
client,
|
||||
@ -110,7 +112,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const endpoint = hostWithScheme(active.host, active.scheme)
|
||||
|
||||
let serverInfo: ServerVersionResponse | undefined
|
||||
try {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } 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
|
||||
readonly active?: ActiveContext
|
||||
}
|
||||
|
||||
export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
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
|
||||
const ctx = inputs.active?.ctx
|
||||
if (ctx !== undefined) {
|
||||
if (truthy(ctx.workspace?.id))
|
||||
return ctx.workspace.id
|
||||
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
|
||||
&& truthy(ctx.available_workspaces[0]?.id)) {
|
||||
return ctx.available_workspaces[0].id
|
||||
}
|
||||
}
|
||||
throw new BaseError({
|
||||
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -1,6 +1,7 @@
|
||||
export type Scenario
|
||||
= | 'happy'
|
||||
| 'sso'
|
||||
| 'no-email'
|
||||
| 'denied'
|
||||
| 'expired'
|
||||
| 'auth-expired'
|
||||
|
||||
10
cli/test/fixtures/dify-mock/server.ts
vendored
10
cli/test/fixtures/dify-mock/server.ts
vendored
@ -362,6 +362,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
if (scenario === 'no-email') {
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
account: { id: ACCOUNT.id, email: '', name: '' },
|
||||
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
default_workspace_id: 'ws-1',
|
||||
token_id: 'tok-1',
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
|
||||
@ -7,13 +7,9 @@ When('I open the publish panel', async function (this: DifyWorld) {
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
await this.getPage()
|
||||
.getByRole('button', { name: /Publish Update/ })
|
||||
.click()
|
||||
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
|
||||
})
|
||||
|
||||
Then('the app should be marked as published', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import {
|
||||
createTestApp,
|
||||
enableAppSiteAndGetURL,
|
||||
publishWorkflowApp,
|
||||
syncRunnableWorkflowDraft,
|
||||
} from '../../../support/api'
|
||||
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
|
||||
|
||||
When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
|
||||
)
|
||||
}
|
||||
if (!appName)
|
||||
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
|
||||
|
||||
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
|
||||
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
|
||||
@ -36,11 +28,8 @@ Given('a workflow app has been published and shared via API', async function (th
|
||||
})
|
||||
|
||||
When('I open the shared app URL', async function (this: DifyWorld) {
|
||||
if (!this.shareURL) {
|
||||
throw new Error(
|
||||
'No share URL available. Run "a workflow app has been published and shared via API" first.',
|
||||
)
|
||||
}
|
||||
if (!this.shareURL)
|
||||
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
|
||||
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
|
||||
})
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const testRunButton = page.getByRole('button', { name: /Test Run/ })
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('DETAIL', { exact: true }).click()
|
||||
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
|
||||
await page.getByText('DETAIL').click()
|
||||
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
@ -5052,11 +5052,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/features/deployments/detail/versions-tab/release-dsl-export.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/hooks/use-async-window-open.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -5150,6 +5145,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/models/access-control.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/models/app.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@ -5542,6 +5542,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/types/feature.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/types/lamejs.d.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -5557,6 +5562,11 @@
|
||||
"count": 17
|
||||
}
|
||||
},
|
||||
"web/utils/clipboard.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/utils/completion-params.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -7,7 +7,6 @@ export type ClientOptions = {
|
||||
export type SystemFeatureModel = {
|
||||
app_dsl_version: string
|
||||
branding: BrandingModel
|
||||
enable_app_deploy: boolean
|
||||
enable_change_email: boolean
|
||||
enable_collaboration_mode: boolean
|
||||
enable_creators_platform: boolean
|
||||
|
||||
@ -89,7 +89,6 @@ export const zWebAppAuthModel = z.object({
|
||||
export const zSystemFeatureModel = z.object({
|
||||
app_dsl_version: z.string().default(''),
|
||||
branding: zBrandingModel,
|
||||
enable_app_deploy: z.boolean().default(false),
|
||||
enable_change_email: z.boolean().default(true),
|
||||
enable_collaboration_mode: z.boolean().default(true),
|
||||
enable_creators_platform: z.boolean().default(false),
|
||||
|
||||
@ -220,7 +220,6 @@ export type SuggestedQuestionsResponse = {
|
||||
export type SystemFeatureModel = {
|
||||
app_dsl_version: string
|
||||
branding: BrandingModel
|
||||
enable_app_deploy: boolean
|
||||
enable_change_email: boolean
|
||||
enable_collaboration_mode: boolean
|
||||
enable_creators_platform: boolean
|
||||
|
||||
@ -366,7 +366,6 @@ export const zWebAppAuthModel = z.object({
|
||||
export const zSystemFeatureModel = z.object({
|
||||
app_dsl_version: z.string().default(''),
|
||||
branding: zBrandingModel,
|
||||
enable_app_deploy: z.boolean().default(false),
|
||||
enable_change_email: z.boolean().default(true),
|
||||
enable_collaboration_mode: z.boolean().default(true),
|
||||
enable_creators_platform: z.boolean().default(false),
|
||||
|
||||
@ -4,505 +4,135 @@ import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zAccessServiceCreateApiKeyBody,
|
||||
zAccessServiceCreateApiKeyPath,
|
||||
zAccessServiceCreateApiKeyResponse,
|
||||
zAccessServiceDeleteApiKeyPath,
|
||||
zAccessServiceDeleteApiKeyResponse,
|
||||
zAccessServiceGetAccessChannelsPath,
|
||||
zAccessServiceGetAccessChannelsResponse,
|
||||
zAccessServiceGetAccessPolicyPath,
|
||||
zAccessServiceGetAccessPolicyResponse,
|
||||
zAccessServiceListApiKeysPath,
|
||||
zAccessServiceListApiKeysResponse,
|
||||
zAccessServicePutAccessPolicyBody,
|
||||
zAccessServicePutAccessPolicyPath,
|
||||
zAccessServicePutAccessPolicyResponse,
|
||||
zAccessServiceUpdateAccessChannelsBody,
|
||||
zAccessServiceUpdateAccessChannelsPath,
|
||||
zAccessServiceUpdateAccessChannelsResponse,
|
||||
zAppInstanceServiceCreateAppInstanceBody,
|
||||
zAppInstanceServiceCreateAppInstanceResponse,
|
||||
zAppInstanceServiceDeleteAppInstancePath,
|
||||
zAppInstanceServiceDeleteAppInstanceResponse,
|
||||
zAppInstanceServiceGetAppInstancePath,
|
||||
zAppInstanceServiceGetAppInstanceResponse,
|
||||
zAppInstanceServiceListAppInstancesQuery,
|
||||
zAppInstanceServiceListAppInstancesResponse,
|
||||
zAppInstanceServiceUpdateAppInstanceBody,
|
||||
zAppInstanceServiceUpdateAppInstancePath,
|
||||
zAppInstanceServiceUpdateAppInstanceResponse,
|
||||
zDeploymentServiceCancelDeploymentBody,
|
||||
zDeploymentServiceCancelDeploymentPath,
|
||||
zDeploymentServiceCancelDeploymentResponse,
|
||||
zDeploymentServiceCreateInitialDeploymentFromDslBody,
|
||||
zDeploymentServiceCreateInitialDeploymentFromDslResponse,
|
||||
zDeploymentServiceCreateInitialDeploymentFromSourceAppBody,
|
||||
zDeploymentServiceCreateInitialDeploymentFromSourceAppResponse,
|
||||
zDeploymentServiceDeployBody,
|
||||
zDeploymentServiceDeployPath,
|
||||
zDeploymentServiceDeployResponse,
|
||||
zDeploymentServiceListDeploymentsPath,
|
||||
zDeploymentServiceListDeploymentsQuery,
|
||||
zDeploymentServiceListDeploymentsResponse,
|
||||
zDeploymentServiceListEnvironmentDeploymentsPath,
|
||||
zDeploymentServiceListEnvironmentDeploymentsResponse,
|
||||
zDeploymentServicePromoteBody,
|
||||
zDeploymentServicePromotePath,
|
||||
zDeploymentServicePromoteResponse,
|
||||
zDeploymentServiceRollbackBody,
|
||||
zDeploymentServiceRollbackPath,
|
||||
zDeploymentServiceRollbackResponse,
|
||||
zDeploymentServiceUndeployBody,
|
||||
zDeploymentServiceUndeployPath,
|
||||
zDeploymentServiceUndeployResponse,
|
||||
zEnvironmentServiceListDeployableEnvironmentsQuery,
|
||||
zEnvironmentServiceListDeployableEnvironmentsResponse,
|
||||
zReleaseServiceCreateReleaseFromDslBody,
|
||||
zReleaseServiceCreateReleaseFromDslResponse,
|
||||
zReleaseServiceCreateReleaseFromSourceAppBody,
|
||||
zReleaseServiceCreateReleaseFromSourceAppResponse,
|
||||
zReleaseServiceDeleteReleasePath,
|
||||
zReleaseServiceDeleteReleaseResponse,
|
||||
zReleaseServiceGetDeploymentOptionsFromDslBody,
|
||||
zReleaseServiceGetDeploymentOptionsFromDslResponse,
|
||||
zReleaseServiceGetDeploymentOptionsFromSourceAppBody,
|
||||
zReleaseServiceGetDeploymentOptionsFromSourceAppResponse,
|
||||
zReleaseServiceGetReleasePath,
|
||||
zReleaseServiceGetReleaseResponse,
|
||||
zReleaseServiceListReleaseCredentialCandidatesPath,
|
||||
zReleaseServiceListReleaseCredentialCandidatesResponse,
|
||||
zReleaseServiceListReleasesPath,
|
||||
zReleaseServiceListReleasesQuery,
|
||||
zReleaseServiceListReleasesResponse,
|
||||
zReleaseServiceUpdateReleaseBody,
|
||||
zReleaseServiceUpdateReleasePath,
|
||||
zReleaseServiceUpdateReleaseResponse,
|
||||
zConsoleSsoOAuth2LoginResponse,
|
||||
zConsoleSsoOidcLoginResponse,
|
||||
zConsoleSsoSamlLoginResponse,
|
||||
zWebAppAuthGetGroupSubjectsQuery,
|
||||
zWebAppAuthGetGroupSubjectsResponse,
|
||||
zWebAppAuthGetWebAppAccessModeQuery,
|
||||
zWebAppAuthGetWebAppAccessModeResponse,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsQuery,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsResponse,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppQuery,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppResponse,
|
||||
zWebAppAuthSearchForWhilteListCandidatesQuery,
|
||||
zWebAppAuthSearchForWhilteListCandidatesResponse,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsBody,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const deleteApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'AccessService_DeleteApiKey',
|
||||
path: '/enterprise/app-deploy/api-keys/{apiKeyId}',
|
||||
tags: ['AccessService'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceDeleteApiKeyPath }))
|
||||
.output(zAccessServiceDeleteApiKeyResponse)
|
||||
|
||||
export const getAccessChannels = oc
|
||||
export const oAuth2Login = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_GetAccessChannels',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_OAuth2Login',
|
||||
path: '/enterprise/sso/oauth2/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceGetAccessChannelsPath }))
|
||||
.output(zAccessServiceGetAccessChannelsResponse)
|
||||
.output(zConsoleSsoOAuth2LoginResponse)
|
||||
|
||||
export const updateAccessChannels = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'AccessService_UpdateAccessChannels',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
|
||||
tags: ['AccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAccessServiceUpdateAccessChannelsBody,
|
||||
params: zAccessServiceUpdateAccessChannelsPath,
|
||||
}),
|
||||
)
|
||||
.output(zAccessServiceUpdateAccessChannelsResponse)
|
||||
|
||||
export const getAccessPolicy = oc
|
||||
export const oidcLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_GetAccessPolicy',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_OIDCLogin',
|
||||
path: '/enterprise/sso/oidc/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceGetAccessPolicyPath }))
|
||||
.output(zAccessServiceGetAccessPolicyResponse)
|
||||
.output(zConsoleSsoOidcLoginResponse)
|
||||
|
||||
export const putAccessPolicy = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'AccessService_PutAccessPolicy',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
|
||||
tags: ['AccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAccessServicePutAccessPolicyBody,
|
||||
params: zAccessServicePutAccessPolicyPath,
|
||||
}),
|
||||
)
|
||||
.output(zAccessServicePutAccessPolicyResponse)
|
||||
|
||||
export const listApiKeys = oc
|
||||
export const samlLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_ListApiKeys',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_SAMLLogin',
|
||||
path: '/enterprise/sso/saml/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceListApiKeysPath }))
|
||||
.output(zAccessServiceListApiKeysResponse)
|
||||
.output(zConsoleSsoSamlLoginResponse)
|
||||
|
||||
export const createApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AccessService_CreateApiKey',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
|
||||
tags: ['AccessService'],
|
||||
})
|
||||
.input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath }))
|
||||
.output(zAccessServiceCreateApiKeyResponse)
|
||||
|
||||
export const accessService = {
|
||||
deleteApiKey,
|
||||
getAccessChannels,
|
||||
updateAccessChannels,
|
||||
getAccessPolicy,
|
||||
putAccessPolicy,
|
||||
listApiKeys,
|
||||
createApiKey,
|
||||
export const consoleSso = {
|
||||
oAuth2Login,
|
||||
oidcLogin,
|
||||
samlLogin,
|
||||
}
|
||||
|
||||
export const listAppInstances = oc
|
||||
export const getWebAppAccessMode = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_ListAppInstances',
|
||||
path: '/enterprise/app-deploy/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_GetWebAppAccessMode',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
|
||||
.output(zAppInstanceServiceListAppInstancesResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppAccessModeQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppAccessModeResponse)
|
||||
|
||||
export const createAppInstance = oc
|
||||
export const updateWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppInstanceService_CreateAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_UpdateWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
|
||||
.output(zAppInstanceServiceCreateAppInstanceResponse)
|
||||
.input(z.object({ body: zWebAppAuthUpdateWebAppWhitelistSubjectsBody }))
|
||||
.output(zWebAppAuthUpdateWebAppWhitelistSubjectsResponse)
|
||||
|
||||
export const deleteAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'AppInstanceService_DeleteAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath }))
|
||||
.output(zAppInstanceServiceDeleteAppInstanceResponse)
|
||||
|
||||
export const getAppInstance = oc
|
||||
export const searchForWhilteListCandidates = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_GetAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_SearchForWhilteListCandidates',
|
||||
path: '/enterprise/webapp/app/subject/search',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
|
||||
.output(zAppInstanceServiceGetAppInstanceResponse)
|
||||
.input(z.object({ query: zWebAppAuthSearchForWhilteListCandidatesQuery.optional() }))
|
||||
.output(zWebAppAuthSearchForWhilteListCandidatesResponse)
|
||||
|
||||
export const updateAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'AppInstanceService_UpdateAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppInstanceServiceUpdateAppInstanceBody,
|
||||
params: zAppInstanceServiceUpdateAppInstancePath,
|
||||
}),
|
||||
)
|
||||
.output(zAppInstanceServiceUpdateAppInstanceResponse)
|
||||
|
||||
export const appInstanceService = {
|
||||
listAppInstances,
|
||||
createAppInstance,
|
||||
deleteAppInstance,
|
||||
getAppInstance,
|
||||
updateAppInstance,
|
||||
}
|
||||
|
||||
export const listDeployments = oc
|
||||
export const getWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'DeploymentService_ListDeployments',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/deployments',
|
||||
tags: ['DeploymentService'],
|
||||
operationId: 'WebAppAuth_GetWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zDeploymentServiceListDeploymentsPath,
|
||||
query: zDeploymentServiceListDeploymentsQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zDeploymentServiceListDeploymentsResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppWhitelistSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppWhitelistSubjectsResponse)
|
||||
|
||||
export const listEnvironmentDeployments = oc
|
||||
export const getGroupSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'DeploymentService_ListEnvironmentDeployments',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environment-deployments',
|
||||
tags: ['DeploymentService'],
|
||||
operationId: 'WebAppAuth_GetGroupSubjects',
|
||||
path: '/enterprise/webapp/group/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath }))
|
||||
.output(zDeploymentServiceListEnvironmentDeploymentsResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetGroupSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetGroupSubjectsResponse)
|
||||
|
||||
export const cancelDeployment = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_CancelDeployment',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/cancel',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zDeploymentServiceCancelDeploymentBody,
|
||||
params: zDeploymentServiceCancelDeploymentPath,
|
||||
}),
|
||||
)
|
||||
.output(zDeploymentServiceCancelDeploymentResponse)
|
||||
|
||||
export const deploy = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_Deploy',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/deploy',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServiceDeployBody, params: zDeploymentServiceDeployPath }))
|
||||
.output(zDeploymentServiceDeployResponse)
|
||||
|
||||
export const rollback = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_Rollback',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/rollback',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath }))
|
||||
.output(zDeploymentServiceRollbackResponse)
|
||||
|
||||
export const undeploy = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_Undeploy',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/undeploy',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath }))
|
||||
.output(zDeploymentServiceUndeployResponse)
|
||||
|
||||
export const promote = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_Promote',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/promote',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath }))
|
||||
.output(zDeploymentServicePromoteResponse)
|
||||
|
||||
export const createInitialDeploymentFromDsl = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_CreateInitialDeploymentFromDSL',
|
||||
path: '/enterprise/app-deploy/initial-deployments/dsl',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServiceCreateInitialDeploymentFromDslBody }))
|
||||
.output(zDeploymentServiceCreateInitialDeploymentFromDslResponse)
|
||||
|
||||
export const createInitialDeploymentFromSourceApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_CreateInitialDeploymentFromSourceApp',
|
||||
path: '/enterprise/app-deploy/initial-deployments/source-app',
|
||||
tags: ['DeploymentService'],
|
||||
})
|
||||
.input(z.object({ body: zDeploymentServiceCreateInitialDeploymentFromSourceAppBody }))
|
||||
.output(zDeploymentServiceCreateInitialDeploymentFromSourceAppResponse)
|
||||
|
||||
export const deploymentService = {
|
||||
listDeployments,
|
||||
listEnvironmentDeployments,
|
||||
cancelDeployment,
|
||||
deploy,
|
||||
rollback,
|
||||
undeploy,
|
||||
promote,
|
||||
createInitialDeploymentFromDsl,
|
||||
createInitialDeploymentFromSourceApp,
|
||||
}
|
||||
|
||||
export const listReleases = oc
|
||||
export const isUserAllowedToAccessWebApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ReleaseService_ListReleases',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/releases',
|
||||
tags: ['ReleaseService'],
|
||||
operationId: 'WebAppAuth_IsUserAllowedToAccessWebApp',
|
||||
path: '/enterprise/webapp/permission',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zReleaseServiceListReleasesPath,
|
||||
query: zReleaseServiceListReleasesQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zReleaseServiceListReleasesResponse)
|
||||
.input(z.object({ query: zWebAppAuthIsUserAllowedToAccessWebAppQuery.optional() }))
|
||||
.output(zWebAppAuthIsUserAllowedToAccessWebAppResponse)
|
||||
|
||||
export const getDeploymentOptionsFromDsl = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'ReleaseService_GetDeploymentOptionsFromDSL',
|
||||
path: '/enterprise/app-deploy/deployment-options/dsl',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ body: zReleaseServiceGetDeploymentOptionsFromDslBody }))
|
||||
.output(zReleaseServiceGetDeploymentOptionsFromDslResponse)
|
||||
|
||||
export const getDeploymentOptionsFromSourceApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'ReleaseService_GetDeploymentOptionsFromSourceApp',
|
||||
path: '/enterprise/app-deploy/deployment-options/source-app',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ body: zReleaseServiceGetDeploymentOptionsFromSourceAppBody }))
|
||||
.output(zReleaseServiceGetDeploymentOptionsFromSourceAppResponse)
|
||||
|
||||
export const createReleaseFromDsl = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'ReleaseService_CreateReleaseFromDSL',
|
||||
path: '/enterprise/app-deploy/releases/dsl',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ body: zReleaseServiceCreateReleaseFromDslBody }))
|
||||
.output(zReleaseServiceCreateReleaseFromDslResponse)
|
||||
|
||||
export const createReleaseFromSourceApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'ReleaseService_CreateReleaseFromSourceApp',
|
||||
path: '/enterprise/app-deploy/releases/source-app',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ body: zReleaseServiceCreateReleaseFromSourceAppBody }))
|
||||
.output(zReleaseServiceCreateReleaseFromSourceAppResponse)
|
||||
|
||||
export const deleteRelease = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'ReleaseService_DeleteRelease',
|
||||
path: '/enterprise/app-deploy/releases/{releaseId}',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ params: zReleaseServiceDeleteReleasePath }))
|
||||
.output(zReleaseServiceDeleteReleaseResponse)
|
||||
|
||||
export const getRelease = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ReleaseService_GetRelease',
|
||||
path: '/enterprise/app-deploy/releases/{releaseId}',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ params: zReleaseServiceGetReleasePath }))
|
||||
.output(zReleaseServiceGetReleaseResponse)
|
||||
|
||||
export const updateRelease = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'ReleaseService_UpdateRelease',
|
||||
path: '/enterprise/app-deploy/releases/{releaseId}',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(
|
||||
z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }),
|
||||
)
|
||||
.output(zReleaseServiceUpdateReleaseResponse)
|
||||
|
||||
export const listReleaseCredentialCandidates = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ReleaseService_ListReleaseCredentialCandidates',
|
||||
path: '/enterprise/app-deploy/releases/{releaseId}/credential-candidates',
|
||||
tags: ['ReleaseService'],
|
||||
})
|
||||
.input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath }))
|
||||
.output(zReleaseServiceListReleaseCredentialCandidatesResponse)
|
||||
|
||||
export const releaseService = {
|
||||
listReleases,
|
||||
getDeploymentOptionsFromDsl,
|
||||
getDeploymentOptionsFromSourceApp,
|
||||
createReleaseFromDsl,
|
||||
createReleaseFromSourceApp,
|
||||
deleteRelease,
|
||||
getRelease,
|
||||
updateRelease,
|
||||
listReleaseCredentialCandidates,
|
||||
}
|
||||
|
||||
export const listDeployableEnvironments = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'EnvironmentService_ListDeployableEnvironments',
|
||||
path: '/enterprise/app-deploy/deployable-environments',
|
||||
tags: ['EnvironmentService'],
|
||||
})
|
||||
.input(z.object({ query: zEnvironmentServiceListDeployableEnvironmentsQuery.optional() }))
|
||||
.output(zEnvironmentServiceListDeployableEnvironmentsResponse)
|
||||
|
||||
export const environmentService = {
|
||||
listDeployableEnvironments,
|
||||
export const webAppAuth = {
|
||||
getWebAppAccessMode,
|
||||
updateWebAppWhitelistSubjects,
|
||||
searchForWhilteListCandidates,
|
||||
getWebAppWhitelistSubjects,
|
||||
getGroupSubjects,
|
||||
isUserAllowedToAccessWebApp,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
accessService,
|
||||
appInstanceService,
|
||||
deploymentService,
|
||||
releaseService,
|
||||
environmentService,
|
||||
consoleSso,
|
||||
webAppAuth,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -10,21 +10,6 @@ type OpenApiDocument = JsonObject & {
|
||||
paths?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type OpenApiMediaType = JsonObject & {
|
||||
schema?: unknown
|
||||
}
|
||||
|
||||
type OpenApiOperation = JsonObject & {
|
||||
operationId?: string
|
||||
responses?: Record<string, OpenApiResponse>
|
||||
}
|
||||
|
||||
type OpenApiPathItem = Record<string, unknown>
|
||||
|
||||
type OpenApiResponse = JsonObject & {
|
||||
content?: Record<string, OpenApiMediaType>
|
||||
}
|
||||
|
||||
type ContractOperation = {
|
||||
id: string
|
||||
operationId?: string
|
||||
@ -36,26 +21,9 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
|
||||
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
|
||||
: path.resolve(currentDir, '../../../dify-enterprise/server')
|
||||
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
|
||||
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
|
||||
|
||||
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
|
||||
|
||||
const isObject = (value: unknown): value is JsonObject => {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => {
|
||||
return isObject(value) ? value as OpenApiOperation : undefined
|
||||
}
|
||||
|
||||
const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => {
|
||||
return isObject(value) ? value as OpenApiResponse : undefined
|
||||
}
|
||||
|
||||
const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => {
|
||||
return isObject(value) ? value as OpenApiMediaType : undefined
|
||||
}
|
||||
|
||||
const stripConsoleApiPrefix = (routePath: string) => {
|
||||
if (isConsoleApiPath(routePath))
|
||||
return routePath.replace('/console/api', '')
|
||||
@ -66,17 +34,9 @@ const stripConsoleApiPrefix = (routePath: string) => {
|
||||
const stripSchemaNamePrefix = (schemaName: string) => {
|
||||
return schemaName
|
||||
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
|
||||
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
|
||||
.replace(/^pagination\./, '')
|
||||
}
|
||||
|
||||
const contractTagSegment = (tag?: string) => {
|
||||
if (tag === 'EnterpriseAppDeployConsole')
|
||||
return 'AppDeploy'
|
||||
|
||||
return tag || 'default'
|
||||
}
|
||||
|
||||
const contractNameSegments = (operation: ContractOperation) => {
|
||||
const operationId = operation.operationId || operation.id
|
||||
const tag = operation.tags?.[0]
|
||||
@ -88,37 +48,7 @@ const contractNameSegments = (operation: ContractOperation) => {
|
||||
}
|
||||
|
||||
const contractPathSegments = (operation: ContractOperation) => {
|
||||
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
|
||||
}
|
||||
|
||||
const hasSchemaLessResponseContent = (operation: OpenApiOperation) => {
|
||||
if (!isObject(operation.responses))
|
||||
return false
|
||||
|
||||
return Object.values(operation.responses).some((response) => {
|
||||
const openApiResponse = asOpenApiResponse(response)
|
||||
if (!openApiResponse || !isObject(openApiResponse.content))
|
||||
return false
|
||||
|
||||
return Object.values(openApiResponse.content).some((mediaType) => {
|
||||
const openApiMediaType = asOpenApiMediaType(mediaType)
|
||||
return !!openApiMediaType && !('schema' in openApiMediaType)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these
|
||||
// raw download operations until the source OpenAPI exposes an explicit schema.
|
||||
const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(pathItem).filter(([method, operation]) => {
|
||||
if (!operationMethods.has(method.toLowerCase()))
|
||||
return true
|
||||
|
||||
const openApiOperation = asOpenApiOperation(operation)
|
||||
return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation)
|
||||
}),
|
||||
)
|
||||
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
|
||||
}
|
||||
|
||||
const normalizeEnterpriseOpenApi = () => {
|
||||
@ -133,13 +63,7 @@ const normalizeEnterpriseOpenApi = () => {
|
||||
document.paths = Object.fromEntries(
|
||||
Object.entries(paths)
|
||||
.filter(([routePath]) => isConsoleApiPath(routePath))
|
||||
.map(([routePath, pathItem]) => {
|
||||
if (!isObject(pathItem))
|
||||
return [stripConsoleApiPrefix(routePath), pathItem]
|
||||
|
||||
return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)]
|
||||
})
|
||||
.filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0),
|
||||
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
|
||||
)
|
||||
|
||||
return document
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -510,9 +510,6 @@ catalogs:
|
||||
scheduler:
|
||||
specifier: 0.27.0
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 0.34.5
|
||||
version: 0.34.5
|
||||
@ -1245,9 +1242,6 @@ importers:
|
||||
scheduler:
|
||||
specifier: 'catalog:'
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 'catalog:'
|
||||
version: 0.34.5
|
||||
@ -8419,9 +8413,6 @@ packages:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@ -16650,8 +16641,6 @@ snapshots:
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@ -17769,7 +17758,6 @@ time:
|
||||
remark-breaks@4.0.0: '2023-09-22T16:45:41.061Z'
|
||||
remark-directive@4.0.0: '2025-02-27T15:15:20.630Z'
|
||||
scheduler@0.27.0: '2025-10-01T21:39:15.208Z'
|
||||
server-only@0.0.1: '2022-09-03T01:07:26.139Z'
|
||||
sharp@0.34.5: '2025-11-06T14:19:40.989Z'
|
||||
shiki@4.1.0: '2026-05-19T07:51:34.358Z'
|
||||
socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z'
|
||||
|
||||
@ -213,7 +213,6 @@ catalog:
|
||||
remark-breaks: 4.0.0
|
||||
remark-directive: 4.0.0
|
||||
scheduler: 0.27.0
|
||||
server-only: 0.0.1
|
||||
sharp: 0.34.5
|
||||
shiki: 4.1.0
|
||||
socket.io-client: 4.8.3
|
||||
|
||||
@ -4,9 +4,6 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
|
||||
NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# The base path for the application
|
||||
NEXT_PUBLIC_BASE_PATH=
|
||||
# Server-only console API origin for server-side requests.
|
||||
# Usually matches CONSOLE_API_URL from Docker deployment; local dev can rely on NEXT_PUBLIC_API_PREFIX fallback.
|
||||
CONSOLE_API_URL=http://localhost:5001
|
||||
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: https://cloud.dify.ai/console/api
|
||||
|
||||
@ -141,6 +141,7 @@ describe('Header Account Dropdown Flow', () => {
|
||||
})
|
||||
|
||||
it('logs out, resets cached user markers, and redirects to signin', async () => {
|
||||
localStorage.setItem('setup_status', 'done')
|
||||
localStorage.setItem('education-reverify-prev-expire-at', '1')
|
||||
localStorage.setItem('education-reverify-has-noticed', '1')
|
||||
localStorage.setItem('education-expired-has-noticed', '1')
|
||||
@ -156,6 +157,7 @@ describe('Header Account Dropdown Flow', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
|
||||
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryClient: undefined as QueryClient | undefined,
|
||||
profileQueryFn: vi.fn(),
|
||||
systemFeaturesQueryFn: vi.fn(),
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${url}`)
|
||||
}),
|
||||
headers: vi.fn(),
|
||||
resolveServerConsoleApiUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
getQueryClientServer: () => mocks.queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: () => mocks.headers(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: (url: string) => mocks.redirect(url),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/server', () => ({
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
serverUserProfileQueryOptions: () => ({
|
||||
queryKey: ['common', 'user-profile'],
|
||||
queryFn: mocks.profileQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/system-features', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CommonLayoutHydrationBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.headers.mockResolvedValue(new Headers({
|
||||
'x-dify-pathname': '/apps',
|
||||
'x-dify-search': '?tag=workflow',
|
||||
}))
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
|
||||
mocks.profileQueryFn.mockResolvedValue({
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
name: 'Dify User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'DEVELOPMENT',
|
||||
},
|
||||
})
|
||||
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
|
||||
})
|
||||
|
||||
it('should hydrate common layout queries and render children', async () => {
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow')
|
||||
})
|
||||
|
||||
it('should redirect setup errors to install', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/install')
|
||||
})
|
||||
|
||||
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
|
||||
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -25,7 +25,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import { getLocalStorageItem, useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@ -55,14 +54,13 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setEventHideHeader(v.payload)
|
||||
setHideHeader(v.payload)
|
||||
})
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
@ -127,7 +125,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = getLocalStorageItem('app-detail-collapse-or-expand', 'expand') || 'expand'
|
||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
import { AccessTab } from '@/features/deployments/detail/access-tab'
|
||||
|
||||
export default async function InstanceDetailAccessPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <AccessTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { DeveloperApiTab } from '@/features/deployments/detail/developer-api-tab'
|
||||
|
||||
export default async function InstanceDetailApiTokensPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeveloperApiTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
|
||||
export default async function InstanceDetailInstancesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeployTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { InstanceDetail } from '@/features/deployments/detail'
|
||||
|
||||
export default async function InstanceDetailLayout({ children, params }: {
|
||||
children: ReactNode
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
|
||||
return (
|
||||
<InstanceDetail appInstanceId={appInstanceId}>
|
||||
{children}
|
||||
</InstanceDetail>
|
||||
)
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
|
||||
|
||||
export default async function InstanceDetailOverviewPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <OverviewTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
export default async function InstanceDetailPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
redirect(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
|
||||
|
||||
export default async function InstanceDetailReleasesPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <VersionsTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import { SettingsTab } from '@/features/deployments/detail/settings-tab'
|
||||
|
||||
export default async function InstanceDetailSettingsPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <SettingsTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export default function CreateDeploymentPage() {
|
||||
const { t } = useTranslation('deployments')
|
||||
useDocumentTitle(t('documentTitle.create'))
|
||||
|
||||
return <CreateDeploymentGuide />
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DeploymentsList } from '@/features/deployments/list'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
export default function DeploymentsPage() {
|
||||
const { t } = useTranslation('deployments')
|
||||
useDocumentTitle(t('documentTitle.list'))
|
||||
return <DeploymentsList />
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import EducationApplyPage from '@/app/education-apply/education-apply-page'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useRouter,
|
||||
@ -28,7 +28,7 @@ export default function EducationApply() {
|
||||
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
|
||||
|
||||
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
|
||||
return <FullScreenLoading />
|
||||
return <RootLoading />
|
||||
|
||||
return <EducationApplyPage />
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import { isLegacyBase401 } from '@/features/account-profile/client'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { isLegacyBase401 } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string }
|
||||
@ -18,7 +18,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
// Showing the "Try again" button here would just flash for a few frames before
|
||||
// the page navigates away, and clicking it would 401 again anyway.
|
||||
if (isLegacyBase401(error))
|
||||
return <FullScreenLoading />
|
||||
return <RootLoading />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user