Compare commits

..

3 Commits

Author SHA1 Message Date
e4c98692f7 refactor(cli): make Registry.load() non-nullable and use requireActive()
Registry.load() now always returns a Registry (empty when no config file
exists) instead of Registry | undefined. The 'no config file' and 'no
active account' states were handled identically by every caller, so they
collapse into one signal: resolveActive() returning undefined.

Callers drop the reg?. guards and the '?? Registry.empty(mode)' fallback.
whoami and logout, which hand-reimplemented requireActive(), now call it
directly.
2026-05-29 04:43:21 -07:00
2fc6febb3f Merge remote-tracking branch 'origin/main' into fix/cli-token-storage-unify
# Conflicts:
#	cli/src/commands/auth/login/login.test.ts
#	cli/src/commands/auth/login/login.ts
2026-05-29 04:27:08 -07:00
0bbe60beb4 refactor(cli): unify token storage behind Store + add host/account switching
Route all credential reads/writes through the Store interface so keychain
and file backends share one code path. Add `use host` / `use account`
commands for multi-account switching, with a zero-dependency arrow-key
dropdown (replaces @inquirer/prompts, which broke under bun --compile).

- login: blue dify spinner during device-flow poll; fail fast with a flag
  hint when host is omitted in a non-TTY (agent) context
- select: custom keypress picker, restores raw mode / cursor on any exit
  including synchronous setup failure
- hosts: remove() no longer clears the active host when a non-active
  account is removed

Tests: 836 passing.
2026-05-29 04:19:48 -07:00
257 changed files with 5063 additions and 19348 deletions

View File

@ -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

View File

@ -9,7 +9,6 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "4-27-app-deploy"
tags:
- "*"

View File

@ -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",

View File

@ -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

View File

@ -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 |

View File

@ -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 |

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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()
})
})

View File

@ -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()
}

View File

@ -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

View File

@ -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/)
})

View File

@ -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

View File

@ -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,

View File

@ -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,

View File

@ -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()

View File

@ -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 */ }
}

View File

@ -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 }),
)
}
}

View File

@ -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)
})
})

View File

@ -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))
}

View File

@ -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 })
}
}

View File

@ -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')

View File

@ -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)
}

View File

@ -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 })
}
}

View File

@ -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',

View File

@ -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`)
}

View File

@ -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 })
}

View File

@ -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,

View File

@ -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(

View File

@ -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 })
}

View File

@ -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,

View File

@ -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) {

View File

@ -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 },
),
})
}

View File

@ -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,
},

View File

@ -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()

View File

@ -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,

View File

@ -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/)
})
})

View File

@ -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
}

View File

@ -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 })
}

View File

@ -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,

View File

@ -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 }
}

View File

@ -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({

View File

@ -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'))

View File

@ -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(

View File

@ -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 },
)
}
}

View File

@ -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 })

View File

@ -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 },
)
}

View File

@ -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>

View File

@ -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])

View File

@ -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 })
}

View File

@ -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,

View File

@ -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(

View File

@ -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: {} },
},
},

View 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 })
}
}

View 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)
})
})

View 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
}

View 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 })
}
}

View 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)
})
})

View 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
}

View File

@ -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,
})

View File

@ -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,

View File

@ -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
}

View 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
View 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()
}
}

View File

@ -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' }),
})

View File

@ -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 {

View File

@ -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({

View File

@ -1,6 +1,7 @@
export type Scenario
= | 'happy'
| 'sso'
| 'no-email'
| 'denied'
| 'expired'
| 'auth-expired'

View File

@ -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',

View File

@ -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 })
})

View File

@ -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 })
})

View File

@ -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 })
})

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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
View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()
})
})

View File

@ -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])

View File

@ -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} />
}

View File

@ -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} />
}

View File

@ -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} />
}

View File

@ -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>
)
}

View File

@ -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} />
}

View File

@ -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`)
}

View File

@ -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} />
}

View File

@ -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} />
}

View File

@ -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 />
}

View File

@ -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 />
}

View File

@ -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 />
}

View File

@ -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