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
139 changed files with 2258 additions and 2433 deletions

View File

@ -209,11 +209,6 @@ class MCPProviderBasePayload(BaseModel):
configuration: dict[str, Any] | None = Field(default_factory=dict)
headers: dict[str, Any] | None = Field(default_factory=dict)
authentication: dict[str, Any] | None = Field(default_factory=dict)
# M3 — user-identity forwarding (M2 backend already supports these on the
# service layer). Defaults preserve pre-M3 behavior for clients that don't
# send the fields yet.
forward_user_identity: bool = False
identity_mode: Literal["off", "idp_token"] = "off"
class MCPProviderCreatePayload(MCPProviderBasePayload):
@ -990,8 +985,6 @@ class ToolProviderMCPApi(Resource):
headers=payload.headers or {},
configuration=configuration,
authentication=authentication,
forward_user_identity=payload.forward_user_identity,
identity_mode=payload.identity_mode,
)
# 2) Try to fetch tools immediately after creation so they appear without a second save.
@ -1059,8 +1052,6 @@ class ToolProviderMCPApi(Resource):
configuration=configuration,
authentication=authentication,
validation_result=validation_result,
forward_user_identity=payload.forward_user_identity,
identity_mode=payload.identity_mode,
)
return {"result": "success"}

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import json
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Literal
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from pydantic import BaseModel
@ -76,14 +76,6 @@ class MCPProviderEntity(BaseModel):
created_at: datetime
updated_at: datetime
# M2 — user-identity forwarding. When forward_user_identity is True AND
# identity_mode is "idp_token", the MCP tool runtime asks dify-enterprise
# to mint a fresh SSO id_token for the calling user and stamps it on the
# outbound MCP request as `Authorization: Bearer <token>`. Defaults keep
# pre-M2 providers unchanged (no forwarding).
forward_user_identity: bool = False
identity_mode: Literal["off", "idp_token"] = "off"
@classmethod
def from_db_model(cls, db_provider: MCPToolProvider) -> MCPProviderEntity:
"""Create entity from database model with decryption"""
@ -104,8 +96,6 @@ class MCPProviderEntity(BaseModel):
icon=db_provider.icon or "",
created_at=db_provider.created_at,
updated_at=db_provider.updated_at,
forward_user_identity=db_provider.forward_user_identity,
identity_mode=db_provider.identity_mode, # type: ignore[arg-type]
)
@property
@ -180,8 +170,6 @@ class MCPProviderEntity(BaseModel):
"updated_at": int(self.updated_at.timestamp()),
"label": I18nObject(en_US=self.name, zh_Hans=self.name).to_dict(),
"description": I18nObject(en_US="", zh_Hans="").to_dict(),
"forward_user_identity": self.forward_user_identity,
"identity_mode": self.identity_mode,
}
# Add configuration

View File

@ -54,14 +54,6 @@ class ToolProviderApiEntity(BaseModel):
configuration: MCPConfiguration | None = Field(
default=None, description="The timeout and sse_read_timeout of the MCP tool"
)
# M3 — user-identity forwarding flags. Round-tripped through the console
# API so the create/edit modal can hydrate the toggle state.
forward_user_identity: bool = Field(
default=False, description="Whether Dify forwards the calling user's SSO identity to this MCP server"
)
identity_mode: str = Field(
default="off", description="Identity-forwarding mechanism: 'off' or 'idp_token'"
)
# Workflow
workflow_app_id: str | None = Field(default=None, description="The app id of the workflow tool")
@ -100,10 +92,6 @@ class ToolProviderApiEntity(BaseModel):
optional_fields.update(self.optional_field("is_dynamic_registration", self.is_dynamic_registration))
optional_fields.update(self.optional_field("masked_headers", self.masked_headers))
optional_fields.update(self.optional_field("original_headers", self.original_headers))
# M3 — forwarding flags. Always emit (False/"off" are valid
# values that the UI must hydrate, not skip).
optional_fields["forward_user_identity"] = self.forward_user_identity
optional_fields["identity_mode"] = self.identity_mode
case ToolProviderType.WORKFLOW:
optional_fields.update(self.optional_field("workflow_app_id", self.workflow_app_id))
case _:

View File

@ -1,4 +1,4 @@
from typing import Any, Literal, Self
from typing import Any, Self
from core.entities.mcp_provider import MCPProviderEntity
from core.mcp.types import Tool as RemoteMCPTool
@ -28,8 +28,6 @@ class MCPToolProviderController(ToolProviderController):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
forward_user_identity: bool = False,
identity_mode: Literal["off", "idp_token"] = "off",
):
super().__init__(entity)
self.entity: ToolProviderEntityWithPlugin = entity
@ -39,8 +37,6 @@ class MCPToolProviderController(ToolProviderController):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.forward_user_identity = forward_user_identity
self.identity_mode: Literal["off", "idp_token"] = identity_mode
@property
def provider_type(self) -> ToolProviderType:
@ -109,8 +105,6 @@ class MCPToolProviderController(ToolProviderController):
headers=entity.headers,
timeout=entity.timeout,
sse_read_timeout=entity.sse_read_timeout,
forward_user_identity=entity.forward_user_identity,
identity_mode=entity.identity_mode,
)
def _validate_credentials(self, user_id: str, credentials: dict[str, Any]):
@ -140,8 +134,6 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
forward_user_identity=self.forward_user_identity,
identity_mode=self.identity_mode,
)
def get_tools(self) -> list[MCPTool]:
@ -159,8 +151,6 @@ class MCPToolProviderController(ToolProviderController):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
forward_user_identity=self.forward_user_identity,
identity_mode=self.identity_mode,
)
for tool_entity in self.entity.tools
]

View File

@ -4,7 +4,7 @@ import base64
import json
import logging
from collections.abc import Generator, Mapping
from typing import Any, Literal, cast
from typing import Any, cast
from core.mcp.auth_client import MCPClientWithAuthRetry
from core.mcp.error import MCPConnectionError
@ -38,8 +38,6 @@ class MCPTool(Tool):
headers: dict[str, str] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
forward_user_identity: bool = False,
identity_mode: Literal["off", "idp_token"] = "off",
):
super().__init__(entity, runtime)
self.tenant_id = tenant_id
@ -49,8 +47,6 @@ class MCPTool(Tool):
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.forward_user_identity = forward_user_identity
self.identity_mode: Literal["off", "idp_token"] = identity_mode
self._latest_usage = LLMUsage.empty_usage()
def tool_provider_type(self) -> ToolProviderType:
@ -64,7 +60,7 @@ class MCPTool(Tool):
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
result = self.invoke_remote_mcp_tool(tool_parameters, user_id=user_id, app_id=app_id)
result = self.invoke_remote_mcp_tool(tool_parameters)
# Extract usage metadata from MCP protocol's _meta field
self._latest_usage = self._derive_usage_from_result(result)
@ -238,8 +234,6 @@ class MCPTool(Tool):
headers=self.headers,
timeout=self.timeout,
sse_read_timeout=self.sse_read_timeout,
forward_user_identity=self.forward_user_identity,
identity_mode=self.identity_mode,
)
def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
@ -252,12 +246,7 @@ class MCPTool(Tool):
if value is not None and not (isinstance(value, str) and value.strip() == "")
}
def invoke_remote_mcp_tool(
self,
tool_parameters: dict[str, Any],
user_id: str | None = None,
app_id: str | None = None,
) -> CallToolResult:
def invoke_remote_mcp_tool(self, tool_parameters: dict[str, Any]) -> CallToolResult:
headers = self.headers.copy() if self.headers else {}
tool_parameters = self._handle_none_parameter(tool_parameters)
@ -282,14 +271,6 @@ class MCPTool(Tool):
if tokens and tokens.access_token:
headers["Authorization"] = f"{tokens.token_type.capitalize()} {tokens.access_token}"
# User-identity forwarding: if enabled on this provider, ask the
# enterprise side to mint a fresh SSO id_token (audience-scoped to
# the MCP server's URL per RFC 8707) and stamp it as Authorization.
# This OVERRIDES any Authorization already on the request — the
# forwarded identity is what the MCP server should trust.
if self.forward_user_identity and self.identity_mode == "idp_token" and user_id:
self._inject_forwarded_identity(headers, user_id=user_id, app_id=app_id, audience=server_url)
# Step 2: Session is now closed, perform network operations without holding database connection
# MCPClientWithAuthRetry will create a new session lazily only if auth retry is needed
try:
@ -305,31 +286,3 @@ class MCPTool(Tool):
raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
except Exception as e:
raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
def _inject_forwarded_identity(
self,
headers: dict[str, str],
*,
user_id: str,
app_id: str | None,
audience: str,
) -> None:
"""Call the enterprise IssueMCPToken endpoint and stamp Authorization.
Errors are surfaced as ToolInvokeError so the workflow halts with a
clear message instead of silently dropping identity and hitting the
MCP server unauthenticated.
"""
from services.enterprise.base import MCPTokenError
from services.enterprise.enterprise_service import EnterpriseService
try:
token, _expires_at = EnterpriseService.issue_mcp_token(
user_id=user_id,
tenant_id=self.tenant_id,
app_id=app_id,
audience=audience,
)
except MCPTokenError as e:
raise ToolInvokeError(f"Failed to obtain forwarded identity token: {e}") from e
headers["Authorization"] = f"Bearer {token}"

View File

@ -1,56 +0,0 @@
"""add identity mode to mcp tool provider
Revision ID: 3df4dbcc1e21
Revises: 7885bd53f9a9
Create Date: 2026-05-29 15:00:00.000000
Adds two columns to `tool_mcp_providers` that drive the M2 MCP user-identity
forwarding feature:
* `forward_user_identity` (bool, default false) — master switch per provider.
* `identity_mode` (string, default "off") — which forwarding mechanism to use:
"off" — no header forwarded (default; pre-M2 behaviour).
"idp_token" — call dify-enterprise /inner/api/mcp/issue-token, stamp
the returned id_token on the outbound MCP request as
`Authorization: Bearer <token>`.
The columns are filled with safe defaults for existing rows so older providers
keep their current behaviour (no identity forwarding) until an admin opts in.
"""
import sqlalchemy as sa
from alembic import op
import models as models
# revision identifiers, used by Alembic.
revision = "3df4dbcc1e21"
down_revision = "7885bd53f9a9"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"tool_mcp_providers",
sa.Column(
"forward_user_identity",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.add_column(
"tool_mcp_providers",
sa.Column(
"identity_mode",
sa.String(length=32),
nullable=False,
server_default=sa.text("'off'"),
),
)
def downgrade():
op.drop_column("tool_mcp_providers", "identity_mode")
op.drop_column("tool_mcp_providers", "forward_user_identity")

View File

@ -343,21 +343,6 @@ class MCPToolProvider(TypeBase):
# encrypted headers for MCP server requests
encrypted_headers: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
# M2 (MCP user-identity forwarding) — master switch per provider. When True
# AND identity_mode is "idp_token", workflows that invoke tools on this
# provider will have the caller's SSO id_token stamped on the outbound
# request as `Authorization: Bearer …`. Off by default so existing
# providers retain pre-M2 behaviour.
forward_user_identity: Mapped[bool] = mapped_column(
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
)
# M2 — which identity-forwarding mechanism to use. Reserved values:
# "off" — no forwarding (default).
# "idp_token" — forward a Bearer id_token minted by dify-enterprise.
identity_mode: Mapped[str] = mapped_column(
sa.String(32), nullable=False, server_default=sa.text("'off'"), default="off"
)
def load_user(self) -> Account | None:
return db.session.scalar(select(Account).where(Account.id == self.user_id))

View File

@ -12,33 +12,8 @@ from services.errors.enterprise import (
EnterpriseAPIForbiddenError,
EnterpriseAPINotFoundError,
EnterpriseAPIUnauthorizedError,
EnterpriseServiceError,
)
# M2 — IssueMCPToken specific errors. Co-located here (rather than in
# services/errors/enterprise.py) because services.enterprise.base is part of
# the leaf-mounted file set the local dev override applies; the errors module
# stays at the EE image's baked-in version.
class MCPTokenError(EnterpriseServiceError):
"""Generic failure of the IssueMCPToken RPC."""
class MCPNoRefreshTokenError(MCPTokenError):
"""The user has no stored SSO refresh_token on the enterprise side.
The workflow should ask them to re-authenticate."""
def __init__(self, description: str = ""):
super().__init__(description, status_code=428)
class MCPIdentityRefreshError(MCPTokenError):
"""The enterprise side tried to refresh the user's SSO refresh_token
against the IdP and failed (revoked/expired/IdP error)."""
def __init__(self, description: str = ""):
super().__init__(description, status_code=401)
logger = logging.getLogger(__name__)

View File

@ -11,16 +11,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
from configs import dify_config
from extensions.ext_redis import redis_client
from services.enterprise.base import (
EnterpriseRequest,
MCPIdentityRefreshError,
MCPNoRefreshTokenError,
MCPTokenError,
)
from services.errors.enterprise import (
EnterpriseAPIError,
EnterpriseAPIUnauthorizedError,
)
from services.enterprise.base import EnterpriseRequest
if TYPE_CHECKING:
from services.feature_service import LicenseStatus
@ -130,62 +121,6 @@ class EnterpriseService:
def get_workspace_info(cls, tenant_id: str):
return EnterpriseRequest.send_request("GET", f"/workspace/{tenant_id}/info")
@classmethod
def issue_mcp_token(
cls,
user_id: str,
tenant_id: str,
app_id: str | None,
audience: str,
) -> tuple[str, int]:
"""Mint a short-lived SSO id_token (or OAuth2 access_token) representing
the calling Dify user, audience-scoped to the given MCP server identifier.
Used by MCPTool.invoke_remote_mcp_tool to stamp `Authorization: Bearer
<token>` on outbound MCP requests when the provider has
forward_user_identity=True and identity_mode="idp_token".
Returns:
(token, expires_at_unix_seconds)
Raises:
MCPNoRefreshTokenError: user has no stored SSO refresh_token on the
enterprise side; surface to the workflow as "please log in via SSO".
MCPIdentityRefreshError: enterprise tried to refresh against the IdP
and the IdP rejected (revoked/expired session).
MCPTokenError: any other failure of the enterprise endpoint.
"""
try:
response = EnterpriseRequest.send_request(
"POST",
"/mcp/issue-token",
json={
"user_id": user_id,
"tenant_id": tenant_id,
"app_id": app_id or "",
"audience": audience,
},
)
except EnterpriseAPIUnauthorizedError as e:
# Enterprise side returns 401 when the IdP rejected the refresh.
raise MCPIdentityRefreshError(str(e) or "identity refresh failed; please re-authenticate") from e
except EnterpriseAPIError as e:
# Map the 428 PreconditionRequired we emit on no-stored-refresh-token.
if getattr(e, "status_code", None) == 428:
raise MCPNoRefreshTokenError(
str(e) or "user has no stored SSO refresh token; please re-authenticate"
) from e
raise MCPTokenError(f"issue_mcp_token failed: {e}") from e
if not isinstance(response, dict):
raise MCPTokenError("invalid response shape from enterprise /mcp/issue-token")
token = response.get("token")
expires_at = response.get("expires_at")
if not token or not isinstance(token, str) or not isinstance(expires_at, int):
raise MCPTokenError(f"missing token/expires_at in enterprise response: {response}")
return token, expires_at
@classmethod
def initiate_device_flow_sso(cls, signed_state: str) -> dict:
return EnterpriseRequest.send_request(

View File

@ -4,7 +4,7 @@ import logging
from collections.abc import Mapping
from datetime import datetime
from enum import StrEnum
from typing import Any, Literal
from typing import Any
from urllib.parse import urlparse
from pydantic import BaseModel, Field
@ -136,8 +136,6 @@ class MCPToolManageService:
configuration: MCPConfiguration,
authentication: MCPAuthentication | None = None,
headers: dict[str, str] | None = None,
forward_user_identity: bool = False,
identity_mode: Literal["off", "idp_token"] = "off",
) -> ToolProviderApiEntity:
"""Create a new MCP provider."""
# Validate URL format
@ -173,8 +171,6 @@ class MCPToolManageService:
sse_read_timeout=configuration.sse_read_timeout,
encrypted_headers=encrypted_headers,
encrypted_credentials=encrypted_credentials,
forward_user_identity=forward_user_identity,
identity_mode=identity_mode,
)
self._session.add(mcp_tool)
@ -198,8 +194,6 @@ class MCPToolManageService:
configuration: MCPConfiguration,
authentication: MCPAuthentication | None = None,
validation_result: ServerUrlValidationResult | None = None,
forward_user_identity: bool | None = None,
identity_mode: Literal["off", "idp_token"] | None = None,
) -> None:
"""
Update an MCP provider.
@ -261,14 +255,6 @@ class MCPToolManageService:
if authentication and authentication.client_id:
mcp_provider.encrypted_credentials = self._process_credentials(authentication, mcp_provider, tenant_id)
# Update user-identity forwarding settings if provided.
# None means "leave unchanged" so this stays backwards-compatible
# with existing callers that don't know about M2.
if forward_user_identity is not None:
mcp_provider.forward_user_identity = forward_user_identity
if identity_mode is not None:
mcp_provider.identity_mode = identity_mode
# Flush changes to database
self._session.flush()

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

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 +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">

View File

@ -1,86 +0,0 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { headers } from '@/next/headers'
import { redirect } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { basePath } from '@/utils/var'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
const ACCOUNT_PROFILE_PATH = '/account/profile'
const AUTH_REFRESH_PATH = '/auth/refresh'
type ConsoleErrorPayload = {
code?: string
}
const isConsoleErrorPayload = (value: unknown): value is ConsoleErrorPayload =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPayload | null> => {
try {
const payload: unknown = await error.clone().json()
return isConsoleErrorPayload(payload) ? payload : null
}
catch {
return null
}
}
const getCurrentPath = async () => {
const requestHeaders = await headers()
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/apps`
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
return `${pathname}${search}`
}
const redirectToAuthRefresh = async () => {
const currentPath = await getCurrentPath()
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
}
const handleProfileError = async (error: unknown) => {
if (!(error instanceof Response))
throw error
const errorData = await parseConsoleErrorPayload(error)
if (errorData?.code === 'not_setup')
redirect(`${basePath}/install`)
if (errorData?.code === 'not_init_validated')
redirect(`${basePath}/init`)
if (error.status === 401)
await redirectToAuthRefresh()
throw error
}
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
if (!accountProfileUrl) {
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
try {
await Promise.all([
queryClient.fetchQuery(serverUserProfileQueryOptions()),
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
])
}
catch (error) {
await handleProfileError(error)
}
return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}

View File

@ -1,31 +1,27 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
import RoleRouteGuard from './role-route-guard'
const Layout = async ({ children }: { children: ReactNode }) => {
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@ -44,8 +40,8 @@ const Layout = async ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</CommonLayoutHydrationBoundary>
<Zendesk />
<Zendesk />
</AppInitializer>
</>
)
}

View File

@ -0,0 +1,9 @@
import Loading from '@/app/components/base/loading'
export default function CommonLayoutLoading() {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
<Loading />
</div>
)
}

View File

@ -216,6 +216,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')

View File

@ -16,10 +16,10 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import Collapse from '@/app/components/header/account-setting/collapse'
import { IS_CE_EDITION, validPassword } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { consoleQuery } from '@/service/client'
import { updateUserProfile } from '@/service/common'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
@ -49,7 +49,7 @@ export default function AccountPage() {
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const userProfile = userProfileResp.profile
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
const { isEducationAccount } = useProviderContext()
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')

View File

@ -12,9 +12,8 @@ import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useProviderContext } from '@/context/provider-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
export default function AppSelector() {
const router = useRouter()
@ -32,6 +31,7 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
resetUser()
// Tokens are now stored in cookies and cleared by backend

View File

@ -1,25 +1,21 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import { AppInitializer } from '@/app/components/app-initializer'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
const Layout = async ({ children }: { children: ReactNode }) => {
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
@ -34,7 +30,7 @@ const Layout = async ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</CommonLayoutHydrationBoundary>
</AppInitializer>
</>
)
}

View File

@ -5,9 +5,9 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import Loading from '@/app/components/base/loading'
import Header from '@/app/signin/_header'
import { AppContextProvider } from '@/context/app-context-provider'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
export default function SignInLayout({ children }: any) {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())

View File

@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter, useSearchParams } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
function buildReturnUrl(pathname: string, search: string) {
@ -80,6 +80,7 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = async () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
setOAuthPendingRedirect(returnUrl)
if (isLoggedIn)
await logout()
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)

View File

@ -1,104 +0,0 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
API_PREFIX: 'http://localhost:5001/console/api',
}))
vi.mock('@/config/server', () => ({
SERVER_CONSOLE_API_PREFIX: undefined,
}))
vi.mock('@/utils/var', () => ({
basePath: '',
}))
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values) ? values : []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRequest = (url: string, cookie?: string) => ({
url,
headers: new Headers(cookie ? { cookie } : undefined),
}) as Request
describe('auth refresh route', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
})
it('should refresh cookies and redirect back to the requested path', async () => {
const headers = new Headers()
Object.defineProperty(headers, 'getSetCookie', {
value: () => [
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
],
})
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers,
} as Response)
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
'refresh_token=old-refresh',
))
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:5001/console/api/refresh-token',
expect.objectContaining({
method: 'POST',
cache: 'no-store',
headers: expect.any(Headers),
}),
)
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/apps?category=workflow')
expect(getSetCookieHeaders(response.headers)).toEqual([
'access_token=new-access; Path=/; HttpOnly',
'refresh_token=new-refresh; Path=/; HttpOnly',
])
})
it('should redirect to signin when refresh token is rejected', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
it('should ignore cross-origin redirect targets', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
vi.stubGlobal('fetch', fetchMock)
const { GET } = await import('../route')
const response = await GET(createRequest(
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
'refresh_token=expired',
))
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
})
})

View File

@ -1,113 +0,0 @@
import { API_PREFIX } from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { basePath } from '@/utils/var'
const REFRESH_TOKEN_PATH = '/refresh-token'
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
const resolveAbsoluteUrlPrefix = (value: string) => {
try {
return new URL(value).toString()
}
catch {
return null
}
}
const resolveServerConsoleApiUrl = (pathname: string, requestUrl: URL) => {
const requestPath = withoutLeadingSlash(pathname)
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|| new URL(API_PREFIX, requestUrl.origin).toString()
if (!apiPrefix)
return null
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
}
const resolveSafeRedirectPath = (request: Request) => {
const requestUrl = new URL(request.url)
const redirectUrl = requestUrl.searchParams.get('redirect_url')
if (!redirectUrl)
return DEFAULT_REDIRECT_PATH
try {
const target = new URL(redirectUrl, requestUrl.origin)
if (target.origin !== requestUrl.origin)
return DEFAULT_REDIRECT_PATH
if (target.pathname === AUTH_REFRESH_PATH)
return DEFAULT_REDIRECT_PATH
return `${target.pathname}${target.search}`
}
catch {
return DEFAULT_REDIRECT_PATH
}
}
const getSetCookieHeaders = (headers: Headers) => {
const getSetCookie = Reflect.get(headers, 'getSetCookie')
if (typeof getSetCookie === 'function') {
const values: unknown = getSetCookie.call(headers)
return Array.isArray(values)
? values.filter((value): value is string => typeof value === 'string')
: []
}
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createRedirectResponse = (request: Request, pathname: string, setCookies: string[] = []) => {
const headers = new Headers({
'Cache-Control': 'no-store',
'Location': new URL(pathname, request.url).toString(),
})
for (const cookie of setCookies)
headers.append('Set-Cookie', cookie)
return new Response(null, {
status: 303,
headers,
})
}
const createSigninRedirectResponse = (request: Request, redirectPath: string) =>
createRedirectResponse(request, `${basePath}/signin?redirect_url=${encodeURIComponent(redirectPath)}`)
export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const redirectPath = resolveSafeRedirectPath(request)
const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH, requestUrl)
const cookie = request.headers.get('cookie')
if (!refreshUrl || !cookie)
return createSigninRedirectResponse(request, redirectPath)
try {
const response = await fetch(refreshUrl, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
cookie,
}),
cache: 'no-store',
})
if (!response.ok)
return createSigninRedirectResponse(request, redirectPath)
return createRedirectResponse(request, redirectPath, getSetCookieHeaders(response.headers))
}
catch {
return createSigninRedirectResponse(request, redirectPath)
}
}

View File

@ -0,0 +1,197 @@
import { screen, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { AppInitializer } from '../app-initializer'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
useRouter: vi.fn(),
useSearchParams: vi.fn(),
}))
vi.mock('@/utils/setup-status', () => ({
fetchSetupStatusWithCache: vi.fn(),
}))
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
resolvePostLoginRedirect: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUsePathname = vi.mocked(usePathname)
const mockUseRouter = vi.mocked(useRouter)
const mockUseSearchParams = vi.mocked(useSearchParams)
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
const mockReplace = vi.fn()
describe('AppInitializer', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.unstubAllGlobals()
window.localStorage.clear()
window.sessionStorage.clear()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
mockUsePathname.mockReturnValue('/apps')
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
mockResolvePostLoginRedirect.mockReturnValue(null)
})
it('renders children after setup checks finish', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
})
it('redirects to install when setup status loading fails', async () => {
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('does not persist create app attribution from the url anymore', async () => {
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
})
it('tracks oauth registration with utm info and clears the cookie', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(mockReplace).toHaveBeenCalledWith('/apps')
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
{ searchParams: 'oauth_new_user=true' },
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('stores the education verification flag in localStorage', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
it('redirects to the resolved post-login url when one exists', async () => {
const mockLocationReplace = vi.fn()
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
mockResolvePostLoginRedirect.mockReturnValue('/explore')
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
it('redirects to signin when redirect resolution throws', async () => {
mockResolvePostLoginRedirect.mockImplementation(() => {
throw new Error('redirect resolution failed')
})
renderWithNuqs(
<AppInitializer>
<div>ready</div>
</AppInitializer>,
)
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
expect(screen.queryByText('ready')).not.toBeInTheDocument()
})
})

View File

@ -1,40 +0,0 @@
import { render, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
describe('EducationVerifyActionRecorder', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
})
it('should store the education verification flag when the callback action is present', async () => {
mockUseSearchParams.mockReturnValue(
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
)
render(<EducationVerifyActionRecorder />)
await waitFor(() => {
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
})
})
it('should leave localStorage unchanged for unrelated routes', () => {
render(<EducationVerifyActionRecorder />)
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
})
})

View File

@ -1,106 +0,0 @@
import { render, waitFor } from '@testing-library/react'
import Cookies from 'js-cookie'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSearchParams } from '@/next/navigation'
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
mockSendGAEvent: vi.fn(),
mockTrackEvent: vi.fn(),
}))
vi.mock('@/utils/gtag', () => ({
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
}))
vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(),
}))
vi.mock('../base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
const mockUseSearchParams = vi.mocked(useSearchParams)
const setSearchParams = (searchParams = '') => {
mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>)
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
}
describe('OAuthRegistrationAnalytics', () => {
beforeEach(() => {
vi.clearAllMocks()
Cookies.remove('utm_info')
vi.spyOn(console, 'error').mockImplementation(() => {})
setSearchParams()
})
it('should track oauth registration with utm info and clear the query flag', async () => {
Cookies.set('utm_info', JSON.stringify({
utm_source: 'linkedin',
slug: 'agent-launch',
}))
setSearchParams('oauth_new_user=true&source=signin')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
method: 'oauth',
utm_source: 'linkedin',
slug: 'agent-launch',
})
expect(Cookies.get('utm_info')).toBeUndefined()
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
})
})
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
Cookies.set('utm_info', '{invalid-json')
setSearchParams('oauth_new_user=true')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
})
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
method: 'oauth',
})
expect(console.error).toHaveBeenCalled()
expect(Cookies.get('utm_info')).toBeUndefined()
})
it('should do nothing without the oauth registration query flag', () => {
render(<OAuthRegistrationAnalytics />)
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
it('should clear a false oauth registration query flag without tracking', async () => {
setSearchParams('oauth_new_user=false')
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
render(<OAuthRegistrationAnalytics />)
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
})
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockSendGAEvent).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,103 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import RootLoading from '@/app/loading'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
}
export const AppInitializer = ({
children,
}: AppInitializerProps) => {
const router = useRouter()
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser] = useQueryState(
'oauth_new_user',
parseAsBoolean.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
const setUpStatus = await fetchSetupStatusWithCache()
return setUpStatus.step === 'finished'
}
catch (error) {
console.error(error)
return false
}
}, [])
useEffect(() => {
(async () => {
const action = searchParams.get('action')
if (oauthNewUser) {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
Cookies.remove('utm_info')
}
if (oauthNewUser !== null)
router.replace(pathname)
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
try {
const isFinished = await isSetupFinished()
if (!isFinished) {
router.replace('/install')
return
}
const redirectUrl = resolvePostLoginRedirect(searchParams)
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
}
catch {
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return init ? children : <RootLoading />
}

View File

@ -1,19 +0,0 @@
'use client'
import { useEffect } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { useSearchParams } from '@/next/navigation'
export function EducationVerifyActionRecorder() {
const searchParams = useSearchParams()
useEffect(() => {
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
}, [searchParams])
return null
}

View File

@ -277,6 +277,7 @@ describe('AccountDropdown', () => {
// Assert
await waitFor(() => {
expect(mockLogout).toHaveBeenCalled()
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
expect(mockPush).toHaveBeenCalledWith('/signin')
})
})

View File

@ -121,6 +121,7 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
resetUser()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
// To avoid use other account's education notice info

Some files were not shown because too many files have changed in this diff Show More