Compare commits

..

3 Commits

146 changed files with 2658 additions and 5437 deletions

View File

@ -1,4 +1,4 @@
name: Deploy SaaS
name: Deploy Agent Dev
permissions:
contents: read
@ -7,7 +7,7 @@ on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/saas"
- "deploy/agent-dev"
types:
- completed
@ -16,13 +16,13 @@ jobs:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/saas'
github.event.workflow_run.head_branch == 'deploy/agent-dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.SAAS_DEV_SSH_HOST }}
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT_SAAS_DEV || secrets.SSH_SCRIPT_SAAS_DEV }}
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}

View File

@ -95,51 +95,6 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
permissions:
checks: write
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check changed files
id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
web/**
cli/**
e2e/**
sdks/nodejs-client/**
packages/**
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
eslint.config.mjs
.github/workflows/style.yml
.github/actions/setup-web/**
- name: Setup web environment
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
@ -150,14 +105,28 @@ jobs:
restore-keys: |
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
- name: Style check
- name: Web style check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: .
run: vp run lint:ci
- name: Type check
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: .
run: vp run type-check
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run knip
- name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5

View File

@ -1,17 +1,9 @@
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.agent_fields import (
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
@ -19,40 +11,23 @@ from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
register_schema_models(console_ns, ComposerSavePayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
AgentComposerCandidatesResponse,
AgentComposerImpactResponse,
AgentComposerValidateResponse,
WorkflowAgentComposerResponse,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
200, "Workflow agent composer state", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
_, tenant_id = current_account_with_tenant()
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
),
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -61,24 +36,18 @@ class WorkflowAgentComposerApi(Resource):
def put(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -86,29 +55,21 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
class WorkflowAgentComposerCandidatesApi(Resource):
@console_ns.response(
200, "Workflow agent composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
class WorkflowAgentComposerImpactApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Workflow agent composer impact", console_ns.models[AgentComposerImpactResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -118,21 +79,13 @@ class WorkflowAgentComposerImpactApi(Resource):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
if not current_snapshot_id:
return dump_response(
AgentComposerImpactResponse, {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
)
return dump_response(
AgentComposerImpactResponse,
AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id),
)
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
class WorkflowAgentComposerSaveToRosterApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Workflow agent composer saved to roster", console_ns.models[WorkflowAgentComposerResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -141,34 +94,26 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
def post(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_workflow_composer(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(200, "Agent app composer saved", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -177,23 +122,17 @@ class AgentAppComposerApi(Resource):
def put(self, app_model: App):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
),
return AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_model.id,
account_id=account.id,
payload=payload,
)
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@ -201,20 +140,14 @@ class AgentAppComposerValidateApi(Resource):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
return {"result": "success", "errors": []}
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -4,18 +4,10 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db
from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
from libs.helper import dump_response
from libs.login import current_account_with_tenant, login_required
from services.agent.roster_service import AgentRosterService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
@ -37,14 +29,6 @@ register_schema_models(
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentRosterListResponse,
AgentRosterResponse,
)
def _agent_roster_service() -> AgentRosterService:
@ -53,23 +37,17 @@ def _agent_roster_service() -> AgentRosterService:
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@console_ns.doc(params=query_params_from_model(RosterListQuery))
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
return _agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
)
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -79,49 +57,36 @@ class AgentRosterListApi(Resource):
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
service = _agent_roster_service()
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
return dump_response(
AgentRosterResponse,
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
), 201
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentInviteOptionsResponse,
_agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
),
return _agent_roster_service().list_invite_options(
tenant_id=tenant_id,
page=query.page,
limit=query.limit,
keyword=query.keyword,
app_id=query.app_id,
)
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -129,14 +94,10 @@ class AgentRosterDetailApi(Resource):
def patch(self, agent_id: UUID):
account, tenant_id = current_account_with_tenant()
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentRosterResponse,
_agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
),
return _agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
)
@console_ns.response(204, "Agent archived")
@setup_required
@login_required
@account_initialization_required
@ -149,31 +110,23 @@ class AgentRosterDetailApi(Resource):
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentConfigSnapshotListResponse,
{"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))},
)
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
def get(self, agent_id: UUID, version_id: UUID):
_, tenant_id = current_account_with_tenant()
return dump_response(
AgentConfigSnapshotDetailResponse,
_agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
),
return _agent_roster_service().get_agent_version_detail(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
)

View File

@ -6,7 +6,7 @@ from flask_restx import Resource
from flask_restx.api import HTTPStatus
from pydantic import BaseModel, Field, TypeAdapter
from controllers.common.schema import query_params_from_model, register_schema_models
from controllers.common.schema import register_schema_models
from controllers.console.wraps import edit_permission_required
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
@ -32,19 +32,8 @@ class AnnotationReplyActionPayload(BaseModel):
embedding_model_name: str = Field(description="Embedding model name")
class AnnotationListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, description="Number of annotations per page")
keyword: str = Field(default="", description="Keyword to search annotations")
register_schema_models(
service_api_ns,
AnnotationCreatePayload,
AnnotationReplyActionPayload,
AnnotationListQuery,
Annotation,
AnnotationList,
service_api_ns, AnnotationCreatePayload, AnnotationReplyActionPayload, Annotation, AnnotationList
)
@ -111,7 +100,6 @@ class AnnotationReplyActionStatusApi(Resource):
class AnnotationListApi(Resource):
@service_api_ns.doc("list_annotations")
@service_api_ns.doc(description="List annotations for the application")
@service_api_ns.doc(params=query_params_from_model(AnnotationListQuery))
@service_api_ns.doc(
responses={
200: "Annotations retrieved successfully",
@ -126,18 +114,18 @@ class AnnotationListApi(Resource):
@validate_app_token
def get(self, app_model: App):
"""List annotations for the application."""
query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
keyword = request.args.get("keyword", default="", type=str)
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
app_model.id, query.page, query.limit, query.keyword
)
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(app_model.id, page, limit, keyword)
annotation_models = TypeAdapter(list[Annotation]).validate_python(annotation_list, from_attributes=True)
response = AnnotationList(
data=annotation_models,
has_more=len(annotation_list) == query.limit,
limit=query.limit,
has_more=len(annotation_list) == limit,
limit=limit,
total=total,
page=query.page,
page=page,
)
return response.model_dump(mode="json")

View File

@ -562,16 +562,15 @@ class WorkflowResponseConverter:
outputs, outputs_truncated = self._truncate_mapping(encoded_outputs)
metadata = self._merge_metadata(event.execution_metadata, snapshot)
match event:
case QueueNodeSucceededEvent():
status = WorkflowNodeExecutionStatus.SUCCEEDED
error_message = event.error
case QueueNodeFailedEvent():
status = WorkflowNodeExecutionStatus.FAILED
error_message = event.error
case _:
status = WorkflowNodeExecutionStatus.EXCEPTION
error_message = event.error
if isinstance(event, QueueNodeSucceededEvent):
status = WorkflowNodeExecutionStatus.SUCCEEDED
error_message = event.error
elif isinstance(event, QueueNodeFailedEvent):
status = WorkflowNodeExecutionStatus.FAILED
error_message = event.error
else:
status = WorkflowNodeExecutionStatus.EXCEPTION
error_message = event.error
return NodeFinishStreamResponse(
task_id=task_id,

View File

@ -91,28 +91,26 @@ class AppGeneratorTTSPublisher:
)
future_queue.put(futures_result)
break
else:
match message.event:
case QueueAgentMessageEvent() | QueueLLMChunkEvent():
message_content = message.event.chunk.delta.message.content
if not message_content:
continue
match message_content:
case str():
self.msg_text += message_content
case list():
for content in message_content:
if not isinstance(content, TextPromptMessageContent):
continue
self.msg_text += content.data
case QueueTextChunkEvent():
self.msg_text += message.event.text
case QueueNodeSucceededEvent():
if message.event.outputs is None:
continue
output = message.event.outputs.get("output", "")
if isinstance(output, str):
self.msg_text += output
elif isinstance(message.event, QueueAgentMessageEvent | QueueLLMChunkEvent):
message_content = message.event.chunk.delta.message.content
if not message_content:
continue
match message_content:
case str():
self.msg_text += message_content
case list():
for content in message_content:
if not isinstance(content, TextPromptMessageContent):
continue
self.msg_text += content.data
elif isinstance(message.event, QueueTextChunkEvent):
self.msg_text += message.event.text
elif isinstance(message.event, QueueNodeSucceededEvent):
if message.event.outputs is None:
continue
output = message.event.outputs.get("output", "")
if isinstance(output, str):
self.msg_text += output
self.last_message = message
sentence_arr, text_tmp = self._extract_sentence(self.msg_text)
if len(sentence_arr) >= min(self.max_sentence, 7):

View File

@ -54,39 +54,36 @@ class Blob(BaseModel):
def as_string(self) -> str:
"""Read data as a string."""
match self.data:
case None if self.path:
return Path(str(self.path)).read_text(encoding=self.encoding)
case bytes():
return self.data.decode(self.encoding)
case str():
return self.data
case _:
raise ValueError(f"Unable to get string for blob {self}")
if self.data is None and self.path:
return Path(str(self.path)).read_text(encoding=self.encoding)
elif isinstance(self.data, bytes):
return self.data.decode(self.encoding)
elif isinstance(self.data, str):
return self.data
else:
raise ValueError(f"Unable to get string for blob {self}")
def as_bytes(self) -> bytes:
"""Read data as bytes."""
match self.data:
case bytes():
return self.data
case str():
return self.data.encode(self.encoding)
case None if self.path:
return Path(str(self.path)).read_bytes()
case _:
raise ValueError(f"Unable to get bytes for blob {self}")
if isinstance(self.data, bytes):
return self.data
elif isinstance(self.data, str):
return self.data.encode(self.encoding)
elif self.data is None and self.path:
return Path(str(self.path)).read_bytes()
else:
raise ValueError(f"Unable to get bytes for blob {self}")
@contextlib.contextmanager
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
"""Read data as a byte stream."""
match self.data:
case bytes():
yield BytesIO(self.data)
case None if self.path:
with open(str(self.path), "rb") as f:
yield f
case _:
raise NotImplementedError(f"Unable to convert blob {self}")
if isinstance(self.data, bytes):
yield BytesIO(self.data)
elif self.data is None and self.path:
with open(str(self.path), "rb") as f:
yield f
else:
raise NotImplementedError(f"Unable to convert blob {self}")
@classmethod
def from_path(

View File

@ -1,192 +0,0 @@
from typing import Any, Literal
from pydantic import Field
from fields.base import ResponseModel
from models.agent import (
AgentConfigRevisionOperation,
AgentIconType,
AgentKind,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
)
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
WorkflowNodeJobConfig,
)
from services.entities.agent_entities import (
ComposerCandidateCapabilities,
ComposerSaveStrategy,
ComposerVariant,
)
class AgentConfigSnapshotSummaryResponse(ResponseModel):
id: str
agent_id: str | None = None
version: int
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentRosterResponse(ResponseModel):
id: str
name: str
description: str
icon_type: AgentIconType | None = None
icon: str | None = None
icon_background: str | None = None
agent_kind: AgentKind
scope: AgentScope
source: AgentSource
app_id: str | None = None
workflow_id: str | None = None
workflow_node_id: str | None = None
active_config_snapshot_id: str | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
status: AgentStatus
created_by: str | None = None
updated_by: str | None = None
archived_by: str | None = None
archived_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class AgentInviteOptionResponse(AgentRosterResponse):
is_in_current_workflow: bool = False
in_current_workflow_count: int = 0
existing_node_ids: list[str] = Field(default_factory=list)
class AgentRosterListResponse(ResponseModel):
data: list[AgentRosterResponse]
page: int
limit: int
total: int
has_more: bool
class AgentInviteOptionsResponse(ResponseModel):
data: list[AgentInviteOptionResponse]
page: int
limit: int
total: int
has_more: bool
class AgentConfigRevisionResponse(ResponseModel):
id: str
previous_snapshot_id: str | None = None
current_snapshot_id: str
revision: int
operation: AgentConfigRevisionOperation
summary: str | None = None
version_note: str | None = None
created_by: str | None = None
created_at: str | None = None
class AgentConfigSnapshotDetailResponse(AgentConfigSnapshotSummaryResponse):
config_snapshot: AgentSoulConfig
revisions: list[AgentConfigRevisionResponse] = Field(default_factory=list)
class AgentConfigSnapshotListResponse(ResponseModel):
data: list[AgentConfigSnapshotSummaryResponse]
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
description: str
scope: AgentScope
status: AgentStatus
active_config_snapshot_id: str | None = None
class AgentComposerBindingResponse(ResponseModel):
id: str
binding_type: WorkflowAgentBindingType
agent_id: str | None = None
current_snapshot_id: str | None = None
workflow_id: str
node_id: str
class AgentComposerSoulLockResponse(ResponseModel):
locked: bool
can_unlock: bool = False
reason: str | None = None
class AgentComposerImpactBindingResponse(ResponseModel):
app_id: str
workflow_id: str
node_id: str
class AgentComposerImpactResponse(ResponseModel):
current_snapshot_id: str | None = None
workflow_node_count: int
bindings: list[AgentComposerImpactBindingResponse] = Field(default_factory=list)
class WorkflowAgentComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.WORKFLOW]
agent: AgentComposerAgentResponse | None = None
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
binding: AgentComposerBindingResponse | None = None
soul_lock: AgentComposerSoulLockResponse
agent_soul: AgentSoulConfig
node_job: WorkflowNodeJobConfig
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
class AgentAppComposerResponse(ResponseModel):
variant: Literal[ComposerVariant.AGENT_APP]
agent: AgentComposerAgentResponse
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[dict[str, Any]] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[dict[str, Any]] = Field(default_factory=list)
dify_tools: list[dict[str, Any]] = Field(default_factory=list)
cli_tools: list[dict[str, Any]] = Field(default_factory=list)
knowledge_datasets: list[dict[str, Any]] = Field(default_factory=list)
human_contacts: list[dict[str, Any]] = Field(default_factory=list)
class AgentComposerCandidatesResponse(ResponseModel):
variant: ComposerVariant
allowed_node_job_candidates: AgentComposerNodeJobCandidatesResponse = Field(
default_factory=AgentComposerNodeJobCandidatesResponse
)
allowed_soul_candidates: AgentComposerSoulCandidatesResponse = Field(
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)

View File

@ -343,19 +343,11 @@ Check if activation token is valid
### /agents
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent roster list | [AgentRosterListResponse](#agentrosterlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### POST
##### Parameters
@ -366,27 +358,18 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent created | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/invite-options
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | query | Workflow app id for in-current-workflow markers | No | string |
| keyword | query | | No | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent invite options | [AgentInviteOptionsResponse](#agentinviteoptionsresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}
@ -401,7 +384,7 @@ Check if activation token is valid
| Code | Description |
| ---- | ----------- |
| 204 | Agent archived |
| 200 | Success |
#### GET
##### Parameters
@ -412,9 +395,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent detail | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PATCH
##### Parameters
@ -426,9 +409,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent updated | [AgentRosterResponse](#agentrosterresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions
@ -441,9 +424,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent versions | [AgentConfigSnapshotListResponse](#agentconfigsnapshotlistresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /agents/{agent_id}/versions/{version_id}
@ -457,9 +440,9 @@ Check if activation token is valid
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version detail | [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /all-workspaces
@ -995,9 +978,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer state | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -1009,9 +992,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer saved | [AgentAppComposerResponse](#agentappcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/candidates
@ -1024,9 +1007,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent-composer/validate
@ -1040,9 +1023,9 @@ Run draft workflow for advanced chat application
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent app composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/agent/logs
@ -3241,9 +3224,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer state | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
#### PUT
##### Parameters
@ -3256,9 +3239,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates
@ -3272,9 +3255,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer candidates | [AgentComposerCandidatesResponse](#agentcomposercandidatesresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/impact
@ -3285,13 +3268,12 @@ Run draft workflow loop node
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| node_id | path | | Yes | string |
| payload | body | | Yes | [ComposerSavePayload](#composersavepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer impact | [AgentComposerImpactResponse](#agentcomposerimpactresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/save-to-roster
@ -3306,9 +3288,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer saved to roster | [WorkflowAgentComposerResponse](#workflowagentcomposerresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/validate
@ -3323,9 +3305,9 @@ Run draft workflow loop node
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workflow agent composer validation result | [AgentComposerValidateResponse](#agentcomposervalidateresponse) |
| Code | Description |
| ---- | ----------- |
| 200 | Success |
### /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run
@ -10669,150 +10651,6 @@ Get banner list
| model_mode | string | Model mode | Yes |
| model_name | string | Model name | Yes |
#### AgentAppComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | Yes |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| variant | string | | Yes |
#### AgentComposerAgentResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | No |
| description | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
#### AgentComposerBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| binding_type | [WorkflowAgentBindingType](#workflowagentbindingtype) | | Yes |
| current_snapshot_id | string | | No |
| id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| node_id | string | | Yes |
| workflow_id | string | | Yes |
#### AgentComposerImpactResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| bindings | [ [AgentComposerImpactBindingResponse](#agentcomposerimpactbindingresponse) ] | | No |
| current_snapshot_id | string | | No |
| workflow_node_count | integer | | Yes |
#### AgentComposerNodeJobCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| declare_output_types | [ [DeclaredOutputType](#declaredoutputtype) ] | | No |
| human_contacts | [ object ] | | No |
| previous_node_outputs | [ object ] | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cli_tools | [ object ] | | No |
| dify_tools | [ object ] | | No |
| human_contacts | [ object ] | | No |
| knowledge_datasets | [ object ] | | No |
| skills_files | [ object ] | | No |
#### AgentComposerSoulLockResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| can_unlock | boolean | | No |
| locked | boolean | | Yes |
| reason | string | | No |
#### AgentComposerValidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| result | string | | Yes |
#### AgentConfigRevisionOperation
Audit operation recorded for Agent Soul version/revision changes.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentConfigRevisionOperation | string | Audit operation recorded for Agent Soul version/revision changes. | |
#### AgentConfigRevisionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | string | | No |
| created_by | string | | No |
| current_snapshot_id | string | | Yes |
| id | string | | Yes |
| operation | [AgentConfigRevisionOperation](#agentconfigrevisionoperation) | | Yes |
| previous_snapshot_id | string | | No |
| revision | integer | | Yes |
| summary | string | | No |
| version_note | string | | No |
#### AgentConfigSnapshotDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentConfigSnapshotListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes |
#### AgentConfigSnapshotSummaryResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| id | string | | Yes |
| summary | string | | No |
| version | integer | | Yes |
| version_note | string | | No |
#### AgentIconType
Supported icon storage formats for Agent roster entries.
@ -10827,35 +10665,6 @@ Supported icon storage formats for Agent roster entries.
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
#### AgentInviteOptionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| existing_node_ids | [ string ] | | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| in_current_workflow_count | integer | | No |
| is_in_current_workflow | boolean | | No |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentInviteOptionsQuery
| Name | Type | Description | Required |
@ -10865,27 +10674,6 @@ Supported icon storage formats for Agent roster entries.
| limit | integer | | No |
| page | integer | | No |
#### AgentInviteOptionsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentInviteOptionResponse](#agentinviteoptionresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentKind
Agent implementation family.
This leaves room for future non-Dify agent implementations while keeping
the current roster/workflow APIs scoped to Dify Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentKind | string | Agent implementation family. This leaves room for future non-Dify agent implementations while keeping the current roster/workflow APIs scoped to Dify Agent. | |
#### AgentKnowledgeQueryMode
| Name | Type | Description | Required |
@ -10899,50 +10687,6 @@ the current roster/workflow APIs scoped to Dify Agent.
| conversation_id | string | Conversation UUID | Yes |
| message_id | string | Message UUID | Yes |
#### AgentRosterListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [AgentRosterResponse](#agentrosterresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### AgentRosterResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| active_config_snapshot_id | string | | No |
| agent_kind | [AgentKind](#agentkind) | | Yes |
| app_id | string | | No |
| archived_at | string | | No |
| archived_by | string | | No |
| created_at | string | | No |
| created_by | string | | No |
| description | string | | Yes |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | [AgentIconType](#agenticontype) | | No |
| id | string | | Yes |
| name | string | | Yes |
| scope | [AgentScope](#agentscope) | | Yes |
| source | [AgentSource](#agentsource) | | Yes |
| status | [AgentStatus](#agentstatus) | | Yes |
| updated_at | string | | No |
| updated_by | string | | No |
| workflow_id | string | | No |
| workflow_node_id | string | | No |
#### AgentScope
Visibility and lifecycle scope of an Agent record.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentScope | string | Visibility and lifecycle scope of an Agent record. | |
#### AgentSoulConfig
| Name | Type | Description | Required |
@ -11077,22 +10821,6 @@ Reference to model credentials resolved only at runtime.
| cli_tools | [ object ] | | No |
| dify_tools | [ [AgentSoulDifyToolConfig](#agentsouldifytoolconfig) ] | | No |
#### AgentSource
Origin that created or imported the Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentSource | string | Origin that created or imported the Agent. | |
#### AgentStatus
Soft lifecycle state for Agent records.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentStatus | string | Soft lifecycle state for Agent records. | |
#### AgentThought
| Name | Type | Description | Required |
@ -11800,12 +11528,6 @@ Button styles for user actions.
| binding_type | string | *Enum:* `"inline_agent"`, `"roster_agent"` | Yes |
| current_snapshot_id | string | | No |
#### ComposerCandidateCapabilities
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -15647,32 +15369,6 @@ in form definiton, or a variable while the workflow is running.
| embedding_provider_name | string | | Yes |
| vector_weight | number | | Yes |
#### WorkflowAgentBindingType
How a workflow node is bound to an Agent.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowAgentBindingType | string | How a workflow node is bound to an Agent. | |
#### WorkflowAgentComposerResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot | [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) | | No |
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | No |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| app_id | string | | No |
| binding | [AgentComposerBindingResponse](#agentcomposerbindingresponse) | | No |
| effective_declared_outputs | [ [DeclaredOutputConfig](#declaredoutputconfig) ] | | No |
| impact_summary | [AgentComposerImpactResponse](#agentcomposerimpactresponse) | | No |
| node_id | string | | No |
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| variant | string | | Yes |
| workflow_id | string | | No |
#### WorkflowAppLogPaginationResponse
| Name | Type | Description | Required |

View File

@ -112,14 +112,6 @@ List annotations for the application
List annotations for the application
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Keyword to search annotations | No | string |
| limit | query | Number of annotations per page | No | integer |
| page | query | Page number | No | integer |
##### Responses
| Code | Description | Schema |
@ -2177,14 +2169,6 @@ Returns a list of available models for the specified model type.
| page | integer | | Yes |
| total | integer | | Yes |
#### AnnotationListQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Keyword to search annotations | No |
| limit | integer | Number of annotations per page | No |
| page | integer | Page number | No |
#### AnnotationReplyActionPayload
| Name | Type | Description | Required |

View File

@ -2329,15 +2329,15 @@ class DocumentService:
# if knowledge_config.data_source:
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
#
# # type: ignore
# count = len(upload_file_list)
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# for notion_info in notion_info_list:
# for notion_info in notion_info_list: # type: ignore
# count = count + len(notion_info.pages)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# count = len(website_info.urls)
# count = len(website_info.urls) # type: ignore
# batch_upload_limit = int(dify_config.BATCH_UPLOAD_LIMIT)
# if features.billing.subscription.plan == CloudPlan.SANDBOX and count > 1:
@ -2349,7 +2349,7 @@ class DocumentService:
# # if dataset is empty, update dataset data_source_type
# if not dataset.data_source_type:
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type # type: ignore
# if not dataset.indexing_technique:
# if knowledge_config.indexing_technique not in Dataset.INDEXING_TECHNIQUE_LIST:
@ -2386,7 +2386,7 @@ class DocumentService:
# knowledge_config.retrieval_model.model_dump()
# if knowledge_config.retrieval_model
# else default_retrieval_model
# )
# ) # type: ignore
# documents = []
# if knowledge_config.original_document_id:
@ -2425,8 +2425,8 @@ class DocumentService:
# position = DocumentService.get_documents_position(dataset.id)
# document_ids = []
# duplicate_document_ids = []
# if knowledge_config.data_source.info_list.data_source_type == "upload_file":
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
# if knowledge_config.data_source.info_list.data_source_type == "upload_file": # type: ignore
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids # type: ignore
# for file_id in upload_file_list:
# file = (
# db.session.query(UploadFile)
@ -2452,7 +2452,7 @@ class DocumentService:
# name=file_name,
# ).first()
# if document:
# document.dataset_process_rule_id = dataset_process_rule.id
# document.dataset_process_rule_id = dataset_process_rule.id # type: ignore
# document.updated_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
# document.created_from = created_from
# document.doc_form = knowledge_config.doc_form
@ -2466,8 +2466,8 @@ class DocumentService:
# continue
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
@ -2482,8 +2482,8 @@ class DocumentService:
# document_ids.append(document.id)
# documents.append(document)
# position += 1
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import": # type: ignore
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
# if not notion_info_list:
# raise ValueError("No notion info list found.")
# exist_page_ids = []
@ -2523,8 +2523,8 @@ class DocumentService:
# truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,
@ -2544,8 +2544,8 @@ class DocumentService:
# # delete not selected documents
# if len(exist_document) > 0:
# clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl": # type: ignore
# website_info = knowledge_config.data_source.info_list.website_info_list # type: ignore
# if not website_info:
# raise ValueError("No website info list found.")
# urls = website_info.urls
@ -2563,8 +2563,8 @@ class DocumentService:
# document_name = url
# document = DocumentService.build_document(
# dataset,
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# dataset_process_rule.id, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,

View File

@ -505,7 +505,7 @@ def _truncate_container_database(app: Flask) -> None:
session_factory-created sessions. Truncating after each test gives the suite
a central DB isolation contract that does not depend on which session a test used.
This only covers SQLAlchemy application tables in db.metadata for now;
object storage and custom ad hoc metadata still need their own cleanup.
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
"""
with app.app_context():
db.session.remove()
@ -524,27 +524,13 @@ def _truncate_container_database(app: Flask) -> None:
db.session.remove()
def _flush_container_redis(app: Flask) -> None:
"""
Reset Redis after a container integration test.
Tests in this package share one Redis container for performance. Application
code stores temporary tokens, rate-limit counters, locks, and cache entries
there, so flushing after each test gives Redis-backed state the same
isolation contract as the PostgreSQL container.
"""
with app.app_context():
app.extensions["redis"].flushdb()
@pytest.fixture(autouse=True)
def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]:
"""
Clean DB and Redis state after tests that use the containerized Flask app.
Clean DB state after tests that use the containerized Flask app.
This fixture intentionally does not depend on flask_app_with_containers so
tests under this package do not start the full app/container stack just to
run state cleanup.
non-DB tests under this package do not start the full app/container stack.
"""
yield
@ -552,10 +538,7 @@ def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None
return
app = request.getfixturevalue("flask_app_with_containers")
try:
_truncate_container_database(app)
finally:
_flush_container_redis(app)
_truncate_container_database(app)
@pytest.fixture(scope="package", autouse=True)

View File

@ -1,182 +0,0 @@
preset = "strict"
project-includes = ["."]
search-path = ["../.."]
# Verify project-excludes from the repo root:
# tmp_config=$(mktemp --tmpdir=api/tests/test_containers_integration_tests pyrefly-no-excludes.XXXXXX.toml)
# awk 'BEGIN {skip=0} /^project-excludes = \[/ {skip=1; next} skip && /^\]/ {skip=0; next} !skip {print}' api/tests/test_containers_integration_tests/pyrefly.toml > "$tmp_config"
# tmp_name=$(basename "$tmp_config")
# comm -3 <(sed -n 's/^ "\(.*\)",$/\1/p' api/tests/test_containers_integration_tests/pyrefly.toml | sort) <(uv --directory api run pyrefly check --config "tests/test_containers_integration_tests/$tmp_name" --summary=none --output-format=min-text 2>/dev/null | rg '^ERROR ' | sed -E 's#^ERROR (tests/test_containers_integration_tests/[^:]+):.*#\1#' | sed 's#^tests/test_containers_integration_tests/##' | sort -u)
# rm --force "$tmp_config"
project-excludes = [
"commands/test_legacy_model_type_migration.py",
"conftest.py",
"controllers/console/app/test_app_apis.py",
"controllers/console/app/test_app_import_api.py",
"controllers/console/app/test_chat_conversation_status_count_api.py",
"controllers/console/app/test_conversation_read_timestamp.py",
"controllers/console/app/test_workflow_draft_variable.py",
"controllers/console/auth/test_email_register.py",
"controllers/console/auth/test_forgot_password.py",
"controllers/console/auth/test_oauth.py",
"controllers/console/auth/test_password_reset.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_datasets.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_import.py",
"controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py",
"controllers/console/datasets/test_data_source.py",
"controllers/console/explore/test_conversation.py",
"controllers/console/test_api_based_extension.py",
"controllers/console/test_apikey.py",
"controllers/console/workspace/test_members.py",
"controllers/console/workspace/test_tool_provider.py",
"controllers/console/workspace/test_trigger_providers.py",
"controllers/console/workspace/test_workspace_wraps.py",
"controllers/mcp/test_mcp.py",
"controllers/service_api/dataset/test_dataset.py",
"controllers/service_api/test_site.py",
"controllers/web/test_conversation.py",
"controllers/web/test_site.py",
"controllers/web/test_web_forgot_password.py",
"controllers/web/test_wraps.py",
"core/app/layers/test_pause_state_persist_layer.py",
"core/rag/pipeline/test_queue_integration.py",
"core/rag/retrieval/test_dataset_retrieval_integration.py",
"core/workflow/test_human_input_resume_node_execution.py",
"factories/test_storage_key_loader.py",
"helpers/__init__.py",
"helpers/execution_extra_content.py",
"libs/broadcast_channel/redis/test_channel.py",
"libs/broadcast_channel/redis/test_sharded_channel.py",
"libs/broadcast_channel/redis/test_streams_channel.py",
"libs/test_auto_renew_redis_lock_integration.py",
"libs/test_rate_limiter_integration.py",
"models/test_account.py",
"models/test_conversation_message_inputs.py",
"models/test_types_enum_text.py",
"repositories/test_sqlalchemy_api_workflow_node_execution_repository.py",
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
"repositories/test_sqlalchemy_execution_extra_content_repository.py",
"repositories/test_sqlalchemy_workflow_node_execution_repository.py",
"repositories/test_workflow_run_repository.py",
"services/auth/test_api_key_auth_service.py",
"services/auth/test_auth_integration.py",
"services/dataset_collection_binding.py",
"services/dataset_service_update_delete.py",
"services/document_service_status.py",
"services/enterprise/test_account_deletion_sync.py",
"services/plugin/test_plugin_parameter_service.py",
"services/plugin/test_plugin_permission_service.py",
"services/plugin/test_plugin_service.py",
"services/rag_pipeline/test_rag_pipeline_service_db.py",
"services/recommend_app/test_database_retrieval.py",
"services/test_account_service.py",
"services/test_advanced_prompt_template_service.py",
"services/test_agent_service.py",
"services/test_annotation_service.py",
"services/test_api_based_extension_service.py",
"services/test_api_token_service.py",
"services/test_app_dsl_service.py",
"services/test_app_generate_service.py",
"services/test_app_service.py",
"services/test_attachment_service.py",
"services/test_audio_service_db.py",
"services/test_billing_service.py",
"services/test_conversation_service.py",
"services/test_conversation_service_variables.py",
"services/test_conversation_variable_updater.py",
"services/test_credit_pool_service.py",
"services/test_dataset_permission_service.py",
"services/test_dataset_service.py",
"services/test_dataset_service_batch_update_document_status.py",
"services/test_dataset_service_create_dataset.py",
"services/test_dataset_service_delete_dataset.py",
"services/test_dataset_service_document.py",
"services/test_dataset_service_get_segments.py",
"services/test_dataset_service_permissions.py",
"services/test_dataset_service_retrieval.py",
"services/test_dataset_service_update_dataset.py",
"services/test_delete_archived_workflow_run.py",
"services/test_document_service_display_status.py",
"services/test_document_service_rename_document.py",
"services/test_end_user_service.py",
"services/test_feature_service.py",
"services/test_feedback_service.py",
"services/test_file_service.py",
"services/test_hit_testing_service.py",
"services/test_human_input_delivery_test.py",
"services/test_human_input_delivery_test_service.py",
"services/test_message_export_service.py",
"services/test_message_service.py",
"services/test_message_service_execution_extra_content.py",
"services/test_message_service_extra_contents.py",
"services/test_messages_clean_service.py",
"services/test_metadata_partial_update.py",
"services/test_metadata_service.py",
"services/test_model_load_balancing_service.py",
"services/test_model_provider_service.py",
"services/test_oauth_server_service.py",
"services/test_ops_service.py",
"services/test_recommended_app_service.py",
"services/test_restore_archived_workflow_run.py",
"services/test_saved_message_service.py",
"services/test_schedule_service.py",
"services/test_tag_service.py",
"services/test_trigger_provider_service.py",
"services/test_web_conversation_service.py",
"services/test_webapp_auth_service.py",
"services/test_webhook_service.py",
"services/test_webhook_service_relationships.py",
"services/test_workflow_app_service.py",
"services/test_workflow_draft_variable_service.py",
"services/test_workflow_run_service.py",
"services/test_workflow_service.py",
"services/test_workspace_service.py",
"services/tools/test_api_tools_manage_service.py",
"services/tools/test_mcp_tools_manage_service.py",
"services/tools/test_tools_transform_service.py",
"services/tools/test_workflow_tools_manage_service.py",
"services/workflow/test_workflow_converter.py",
"services/workflow/test_workflow_deletion.py",
"services/workflow/test_workflow_node_execution_service_repository.py",
"tasks/test_add_document_to_index_task.py",
"tasks/test_batch_clean_document_task.py",
"tasks/test_batch_create_segment_to_index_task.py",
"tasks/test_clean_dataset_task.py",
"tasks/test_clean_notion_document_task.py",
"tasks/test_create_segment_to_index_task.py",
"tasks/test_dataset_indexing_task.py",
"tasks/test_deal_dataset_vector_index_task.py",
"tasks/test_delete_account_task.py",
"tasks/test_delete_segment_from_index_task.py",
"tasks/test_disable_segment_from_index_task.py",
"tasks/test_disable_segments_from_index_task.py",
"tasks/test_document_indexing_sync_task.py",
"tasks/test_document_indexing_task.py",
"tasks/test_document_indexing_update_task.py",
"tasks/test_duplicate_document_indexing_task.py",
"tasks/test_enable_segments_to_index_task.py",
"tasks/test_mail_account_deletion_task.py",
"tasks/test_mail_change_mail_task.py",
"tasks/test_mail_email_code_login_task.py",
"tasks/test_mail_human_input_delivery_task.py",
"tasks/test_mail_inner_task.py",
"tasks/test_mail_invite_member_task.py",
"tasks/test_mail_owner_transfer_task.py",
"tasks/test_mail_register_task.py",
"tasks/test_rag_pipeline_run_tasks.py",
"tasks/test_remove_app_and_related_data_task.py",
"test_container_state_isolation.py",
"test_opendal_fs_default_root.py",
"test_workflow_pause_integration.py",
"trigger/conftest.py",
"trigger/test_trigger_e2e.py",
"workflow/nodes/code_executor/test_code_executor.py",
"workflow/nodes/code_executor/test_code_javascript.py",
"workflow/nodes/code_executor/test_code_jinja2.py",
"workflow/nodes/code_executor/test_code_python3.py",
"workflow/nodes/code_executor/test_utils.py",
]
[errors]
unannotated-return = true

View File

@ -1,39 +0,0 @@
from __future__ import annotations
from uuid import uuid4
from extensions.ext_redis import redis_client
from models.account import Account
ACCOUNT_EMAIL = f"container-state-isolation-{uuid4()}@example.com"
REDIS_KEY = f"container-state-isolation:{uuid4()}"
def test_1_container_state_can_be_written(
flask_app_with_containers,
db_session_with_containers,
) -> None:
account = Account(
name="Container State Isolation",
email=ACCOUNT_EMAIL,
password="hashed-password",
password_salt="salt",
interface_language="en-US",
timezone="UTC",
)
db_session_with_containers.add(account)
db_session_with_containers.commit()
with flask_app_with_containers.app_context():
redis_client.set(REDIS_KEY, "leaked")
assert redis_client.get(REDIS_KEY) == b"leaked"
def test_2_container_state_is_flushed_between_tests(
flask_app_with_containers,
db_session_with_containers,
) -> None:
assert db_session_with_containers.query(Account).filter_by(email=ACCOUNT_EMAIL).one_or_none() is None
with flask_app_with_containers.app_context():
assert redis_client.get(REDIS_KEY) is None

View File

@ -30,90 +30,6 @@ def _unwrap(method):
return method
def _agent_response(agent_id: str = "agent-1") -> dict:
return {
"id": agent_id,
"name": "Analyst",
"description": "",
"icon_type": None,
"icon": None,
"icon_background": None,
"agent_kind": "dify_agent",
"scope": "roster",
"source": "agent_app",
"app_id": None,
"workflow_id": None,
"workflow_node_id": None,
"active_config_snapshot_id": "version-1",
"active_config_snapshot": _version_response(),
"status": "active",
"created_by": "account-1",
"updated_by": "account-1",
"archived_by": None,
"archived_at": None,
"created_at": None,
"updated_at": None,
}
def _version_response(version_id: str = "version-1") -> dict:
return {
"id": version_id,
"agent_id": "agent-1",
"version": 1,
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
def _workflow_composer_response(**overrides) -> dict:
response = {
"variant": "workflow",
"agent": None,
"active_config_snapshot": None,
"binding": None,
"soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"},
"agent_soul": {},
"node_job": {},
"effective_declared_outputs": [],
"save_options": ["node_job_only"],
"impact_summary": None,
"app_id": "app-1",
"workflow_id": "workflow-1",
"node_id": "node-1",
}
response.update(overrides)
return response
def _agent_app_composer_response() -> dict:
return {
"variant": "agent_app",
"agent": {
"id": "agent-1",
"name": "Analyst",
"description": "",
"scope": "roster",
"status": "active",
"active_config_snapshot_id": "version-1",
},
"active_config_snapshot": _version_response(),
"agent_soul": {},
"save_options": ["save_to_current_version", "save_as_new_version"],
}
def _candidates_response(variant: str) -> dict:
return {
"variant": variant,
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {},
"capabilities": {"human_roster_available": False},
}
@pytest.fixture
def account():
return SimpleNamespace(id="account-1")
@ -151,15 +67,14 @@ def test_roster_list_post_creates_agent_and_returns_detail(app, monkeypatch):
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"], "tenant_id": kwargs["tenant_id"]},
)
with app.test_request_context(json={"name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}}):
result, status = _unwrap(AgentRosterListApi.post)(AgentRosterListApi())
assert status == 201
assert result["id"] == "agent-1"
assert result["agent_kind"] == "dify_agent"
assert result == {"id": "agent-1", "tenant_id": "tenant-1"}
def test_invite_options_get_parses_app_id(app, monkeypatch):
@ -167,14 +82,14 @@ def test_invite_options_get_parses_app_id(app, monkeypatch):
def list_invite_options(_self, **kwargs):
captured.update(kwargs)
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
return {"data": []}
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
with app.test_request_context("/console/api/agents/invite-options?page=1&limit=10&app_id=app-1"):
result = _unwrap(AgentInviteOptionsApi.get)(AgentInviteOptionsApi())
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
assert result == {"data": []}
assert captured == {"tenant_id": "tenant-1", "page": 1, "limit": 10, "keyword": None, "app_id": "app-1"}
@ -185,12 +100,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_roster_agent_detail",
lambda _self, **kwargs: _agent_response(kwargs["agent_id"]),
lambda _self, **kwargs: {"id": kwargs["agent_id"]},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"update_roster_agent",
lambda _self, **kwargs: {**_agent_response(kwargs["agent_id"]), "description": kwargs["payload"].description},
lambda _self, **kwargs: {"id": kwargs["agent_id"], "description": kwargs["payload"].description},
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
@ -200,29 +115,12 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"list_agent_versions",
lambda _self, **kwargs: [_version_response()],
lambda _self, **kwargs: [{"id": "version-1"}],
)
monkeypatch.setattr(
roster_controller.AgentRosterService,
"get_agent_version_detail",
lambda _self, **kwargs: {
**_version_response(kwargs["version_id"]),
"agent_id": kwargs["agent_id"],
"config_snapshot": {},
"revisions": [
{
"id": "revision-1",
"previous_snapshot_id": None,
"current_snapshot_id": kwargs["version_id"],
"revision": 1,
"operation": "create_version",
"summary": None,
"version_note": None,
"created_by": "account-1",
"created_at": None,
}
],
},
lambda _self, **kwargs: {"id": kwargs["version_id"], "agent_id": kwargs["agent_id"]},
)
assert _unwrap(AgentRosterDetailApi.get)(AgentRosterDetailApi(), agent_id)["id"] == agent_id
@ -230,10 +128,11 @@ def test_roster_detail_patch_delete_and_versions_call_services(app, monkeypatch)
assert _unwrap(AgentRosterDetailApi.patch)(AgentRosterDetailApi(), agent_id)["description"] == "updated"
assert _unwrap(AgentRosterDetailApi.delete)(AgentRosterDetailApi(), agent_id) == ("", 204)
assert archived["account_id"] == "account-1"
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id)["data"][0]["id"] == "version-1"
version_detail = _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id)
assert version_detail["id"] == version_id
assert version_detail["agent_id"] == agent_id
assert _unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), agent_id) == {"data": [{"id": "version-1"}]}
assert _unwrap(AgentRosterVersionDetailApi.get)(AgentRosterVersionDetailApi(), agent_id, version_id) == {
"id": version_id,
"agent_id": agent_id,
}
def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monkeypatch):
@ -246,52 +145,50 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(app, monk
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_workflow_composer",
lambda **kwargs: _workflow_composer_response(node_id=kwargs["node_id"]),
lambda **kwargs: {"node_id": kwargs["node_id"]},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_workflow_composer",
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
lambda **kwargs: {"saved": kwargs["payload"].save_strategy.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_workflow_candidates",
lambda **kwargs: _candidates_response("workflow"),
lambda **kwargs: {"data": []},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"calculate_impact",
lambda **kwargs: {
"current_snapshot_id": kwargs["current_snapshot_id"],
"workflow_node_count": 1,
"bindings": [],
},
lambda **kwargs: {"current_snapshot_id": kwargs["current_snapshot_id"], "workflow_node_count": 1},
)
workflow_state = _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1")
assert workflow_state["node_id"] == "node-1"
assert _unwrap(WorkflowAgentComposerApi.get)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"node_id": "node-1"
}
with app.test_request_context(json=payload):
saved_state = _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1")
assert saved_state["save_options"] == ["node_job_only"]
assert _unwrap(WorkflowAgentComposerApi.put)(WorkflowAgentComposerApi(), app_model, "node-1") == {
"saved": "node_job_only",
"account_id": "account-1",
}
assert _unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert (
_unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
assert _unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), app_model, "node-1"
) == {"data": []}
with app.test_request_context(json=payload):
assert _unwrap(WorkflowAgentComposerImpactApi.post)(WorkflowAgentComposerImpactApi(), app_model, "node-1") == {
"current_snapshot_id": "version-1",
"workflow_node_count": 1,
"bindings": [],
}
assert _unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["save_options"] == ["node_job_only"]
assert (
_unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
WorkflowAgentComposerSaveToRosterApi(), app_model, "node-1"
)["saved"]
== "node_job_only"
)
def test_workflow_impact_returns_empty_without_version(app):
@ -315,26 +212,28 @@ def test_agent_app_composer_get_put_validate_and_candidates(app, monkeypatch):
monkeypatch.setattr(
composer_controller.AgentComposerService,
"load_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"loaded": True},
)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"save_agent_app_composer",
lambda **kwargs: _agent_app_composer_response(),
lambda **kwargs: {"saved": kwargs["payload"].variant.value, "account_id": kwargs["account_id"]},
)
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_save_payload", lambda payload: None)
monkeypatch.setattr(
composer_controller.AgentComposerService,
"get_agent_app_candidates",
lambda **kwargs: _candidates_response("agent_app"),
lambda **kwargs: {"data": []},
)
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.get)(AgentAppComposerApi(), app_model) == {"loaded": True}
with app.test_request_context(json=payload):
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model)["variant"] == "agent_app"
assert _unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), app_model) == {
"saved": "agent_app",
"account_id": "account-1",
}
assert _unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
}
agent_app_candidates = _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"
assert _unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model) == {"data": []}

View File

@ -19,12 +19,10 @@ from unittest.mock import Mock
import pytest
from flask import Flask
from flask_restx.api import HTTPStatus
from pydantic import ValidationError
from controllers.service_api.app.annotation import (
AnnotationCreatePayload,
AnnotationListApi,
AnnotationListQuery,
AnnotationReplyActionApi,
AnnotationReplyActionPayload,
AnnotationReplyActionStatusApi,
@ -108,28 +106,6 @@ class TestAnnotationReplyActionPayload:
assert payload.score_threshold == 0.0
class TestAnnotationListQuery:
def test_defaults(self) -> None:
query = AnnotationListQuery.model_validate({})
assert query.page == 1
assert query.limit == 20
assert query.keyword == ""
def test_valid_numeric_strings(self) -> None:
query = AnnotationListQuery.model_validate({"page": "2", "limit": "5", "keyword": "refund"})
assert query.page == 2
assert query.limit == 5
assert query.keyword == "refund"
@pytest.mark.parametrize("field", ["page", "limit"])
@pytest.mark.parametrize("value", ["abc", "1.5", "1e2", "", "0", "-1"])
def test_invalid_explicit_pagination_value(self, field: str, value: str) -> None:
with pytest.raises(ValidationError):
AnnotationListQuery.model_validate({field: value})
# ---------------------------------------------------------------------------
# Model and Error Pattern Tests
# ---------------------------------------------------------------------------
@ -256,55 +232,22 @@ class TestAnnotationReplyActionStatusApi:
class TestAnnotationListApi:
def test_get_uses_defaults(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_get(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
get_mock = Mock(return_value=([annotation], 1))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
monkeypatch.setattr(
AppAnnotationService,
"get_annotation_list_by_app_id",
lambda *_args, **_kwargs: ([annotation], 1),
)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context("/apps/annotations", method="GET"):
response = handler(api, app_model=app_model)
assert response["page"] == 1
assert response["limit"] == 20
get_mock.assert_called_once_with("app", 1, 20, "")
def test_get_accepts_valid_numeric_strings(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)
get_mock = Mock(return_value=([annotation], 1))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context("/apps/annotations?page=2&limit=5&keyword=refund", method="GET"):
with app.test_request_context("/apps/annotations?page=1&limit=1", method="GET"):
response = handler(api, app_model=app_model)
assert response["total"] == 1
assert response["page"] == 2
assert response["limit"] == 5
get_mock.assert_called_once_with("app", 2, 5, "refund")
@pytest.mark.parametrize("query_string", ["page=abc&limit=5", "page=1&limit=abc", "page=&limit=5", "limit=0"])
def test_get_rejects_invalid_explicit_pagination_value(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, query_string: str
) -> None:
get_mock = Mock(return_value=([], 0))
monkeypatch.setattr(AppAnnotationService, "get_annotation_list_by_app_id", get_mock)
api = AnnotationListApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app")
with app.test_request_context(f"/apps/annotations?{query_string}", method="GET"):
with pytest.raises(ValidationError):
handler(api, app_model=app_model)
get_mock.assert_not_called()
def test_create(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
annotation = SimpleNamespace(id="a1", question="q", content="a", created_at=0)

View File

@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../cache/app-info.js'
import { createClient } from '../http/client.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { AppMetaClient } from './app-meta.js'
import { AppsClient } from './apps.js'
@ -15,24 +15,17 @@ import { AppsClient } from './apps.js'
describe('AppMetaClient', () => {
let mock: DifyMock
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-'))
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
it('cache miss → fetch → populate; warm hit skips network', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })
@ -47,7 +40,7 @@ describe('AppMetaClient', () => {
})
it('slim hit + full request triggers fresh fetch + merges', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })
@ -61,7 +54,7 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') })
@ -75,7 +68,7 @@ describe('AppMetaClient', () => {
})
it('invalidate forces next get to fetch', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache })

View File

@ -40,7 +40,7 @@ export type PollSuccess = {
subject_type?: string
subject_email?: string
subject_issuer?: string
account?: PollAccount | null
account?: PollAccount
workspaces?: readonly PollWorkspace[]
default_workspace_id?: string
token_id?: string

View File

@ -0,0 +1,101 @@
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { FILE_PERM } from '../store/dir.js'
import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js'
describe('FileBackend', () => {
let dir: string
let backend: FileBackend
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-tokens-'))
backend = new FileBackend(dir)
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})
it('returns undefined when file is missing', async () => {
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('returns empty list when file is missing', async () => {
expect(await backend.list('cloud.dify.ai')).toEqual([])
})
it('round-trips put/get for a single token', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_abc')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_abc')
})
it('list returns accountIds for the given host', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.put('cloud.dify.ai', 'acct-2', 'dfoa_b')
await backend.put('self.example.com', 'acct-3', 'dfoa_c')
const ids = await backend.list('cloud.dify.ai')
expect([...ids].sort()).toEqual(['acct-1', 'acct-2'])
})
it('list returns empty array for unknown host', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
expect(await backend.list('other.example.com')).toEqual([])
})
it('delete removes the entry', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.delete('cloud.dify.ai', 'acct-1')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('delete is a no-op for missing entries', async () => {
await expect(backend.delete('cloud.dify.ai', 'missing')).resolves.toBeUndefined()
})
it('delete prunes empty host entries', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.delete('cloud.dify.ai', 'acct-1')
expect(await backend.list('cloud.dify.ai')).toEqual([])
})
it('overwrites existing token for same host+accountId', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_old')
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_new')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_new')
})
it('writes file with mode 0600', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const info = await stat(join(dir, TOKENS_FILE_NAME))
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('rewrites existing file with mode 0600 even if previously permissive', async () => {
const path = join(dir, TOKENS_FILE_NAME)
await writeFile(path, 'hosts: {}\n', { mode: 0o644 })
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const info = await stat(path)
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('writes valid YAML readable by a fresh backend', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
const fresh = new FileBackend(dir)
expect(await fresh.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
})
it('persists multiple hosts simultaneously', async () => {
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
await backend.put('self.example.com', 'acct-2', 'dfoa_b')
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
expect(await backend.get('self.example.com', 'acct-2')).toBe('dfoa_b')
})
it('treats malformed YAML as empty', async () => {
const path = join(dir, TOKENS_FILE_NAME)
await writeFile(path, 'not: valid: yaml: [\n', { mode: FILE_PERM })
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
})

View File

@ -0,0 +1,99 @@
import type { TokenStore } from './store.js'
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const TOKENS_FILE_NAME = 'tokens.yml'
type AccountMap = Record<string, string>
type HostMap = Record<string, AccountMap>
type TokensFile = { hosts?: HostMap }
export class FileBackend implements TokenStore {
private readonly dir: string
private readonly path: string
constructor(dir: string) {
this.dir = dir
this.path = join(dir, TOKENS_FILE_NAME)
}
async put(host: string, accountId: string, token: string): Promise<void> {
const file = await this.read()
const hosts = file.hosts ?? {}
const accounts = hosts[host] ?? {}
accounts[accountId] = token
hosts[host] = accounts
await this.write({ hosts })
}
async get(host: string, accountId: string): Promise<string | undefined> {
const file = await this.read()
return file.hosts?.[host]?.[accountId]
}
async delete(host: string, accountId: string): Promise<void> {
const file = await this.read()
const accounts = file.hosts?.[host]
if (accounts === undefined || !(accountId in accounts))
return
delete accounts[accountId]
if (Object.keys(accounts).length === 0 && file.hosts !== undefined)
delete file.hosts[host]
await this.write(file)
}
async list(host: string): Promise<readonly string[]> {
const file = await this.read()
const accounts = file.hosts?.[host]
return accounts === undefined ? [] : Object.keys(accounts)
}
private async read(): Promise<TokensFile> {
let raw: string
try {
raw = await readFile(this.path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return {}
throw err
}
let parsed: unknown
try {
parsed = yaml.load(raw)
}
catch {
return {}
}
if (parsed === null || typeof parsed !== 'object')
return {}
return parsed as TokensFile
}
private async write(file: TokensFile): Promise<void> {
await mkdir(this.dir, { recursive: true, mode: DIR_PERM })
const body = yaml.dump(file, { lineWidth: -1, noRefs: true })
const tmp = `${this.path}.tmp.${process.pid}.${Date.now()}`
try {
await writeFile(tmp, body, { mode: FILE_PERM })
await rename(tmp, this.path)
}
catch (err) {
try {
await unlink(tmp)
}
catch { /* tmp may not exist */ }
throw err
}
try {
const info = await stat(this.path)
if ((info.mode & 0o777) !== FILE_PERM) {
const { chmod } = await import('node:fs/promises')
await chmod(this.path, FILE_PERM)
}
}
catch { /* best-effort permission tighten */ }
}
}

View File

@ -1,202 +1,131 @@
import type { Key, Store } from '../store/store.js'
import type { AccountContext } from './hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts.js'
import { FILE_PERM } from '../store/dir.js'
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
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({})
describe('HostsBundleSchema', () => {
it('parses a minimal logged-out bundle', () => {
const parsed = HostsBundleSchema.parse({})
expect(parsed.current_host).toBe('')
expect(parsed.token_storage).toBe('file')
})
it('parses a populated multi-host registry', () => {
const reg = RegistrySchema.parse({
token_storage: 'keychain',
it('parses a logged-in keychain bundle', () => {
const parsed = HostsBundleSchema.parse({
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',
},
},
},
},
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
token_storage: 'keychain',
token_id: 'tok_xyz',
})
expect(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')
expect(parsed.token_storage).toBe('keychain')
expect(parsed.tokens).toBeUndefined()
})
it('defaults a host entry accounts map to {}', () => {
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
expect(reg.hosts.h?.accounts).toEqual({})
it('parses a logged-in file bundle with bearer', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_xxx' },
})
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
})
it('rejects unknown token_storage values', () => {
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
})
it('AccountContextSchema keeps optional external_subject', () => {
const ctx = AccountContextSchema.parse({
account: { id: '', email: 'sso@x.io', name: '' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
it('keeps available_workspaces when provided', () => {
const parsed = HostsBundleSchema.parse({
available_workspaces: [
{ id: 'a', name: 'A', role: 'owner' },
{ id: 'b', name: 'B', role: 'member' },
],
})
expect(ctx.external_subject?.issuer).toBe('https://issuer')
expect(parsed.available_workspaces).toHaveLength(2)
})
})
describe('notLoggedInError', () => {
it('carries the default hint', () => {
expect(notLoggedInError().toString()).toMatch(/auth login/)
})
it('accepts a custom hint', () => {
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
})
})
describe('Registry (pure)', () => {
const baseReg = (): Registry => Registry.empty('file')
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
it('upsert creates host + account; remove drops them', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.upsert('h1', 'b@x', ctx('b@x'))
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
reg.remove('h1', 'a@x')
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
reg.remove('h1', 'b@x')
expect(reg.hosts.h1).toBeUndefined()
})
it('setHost / setAccount set pointers', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setHost('h1')
reg.setAccount('a@x')
expect(reg.current_host).toBe('h1')
expect(reg.hosts.h1?.current_account).toBe('a@x')
})
it('resolveActive returns the active context with scheme', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setScheme('h1', 'http')
reg.setHost('h1')
reg.setAccount('a@x')
const active = reg.resolveActive()
expect(active?.host).toBe('h1')
expect(active?.email).toBe('a@x')
expect(active?.scheme).toBe('http')
expect(active?.ctx.account.email).toBe('a@x')
})
it('resolveActive returns 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', () => {
describe('loadHosts/saveHosts', () => {
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
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
})
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('returns an empty registry when nothing saved', () => {
const reg = Registry.load()
expect(reg.current_host).toBeUndefined()
expect(Object.keys(reg.hosts)).toHaveLength(0)
it('returns undefined when file is missing', async () => {
expect(await loadHosts(dir)).toBeUndefined()
})
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()
it('round-trips bundle through YAML', async () => {
await saveHosts(dir, {
current_host: 'cloud.dify.ai',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
token_storage: 'keychain',
token_id: 'tok_xyz',
})
const loaded = await loadHosts(dir)
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect(loaded?.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()
expect(loaded?.account?.email).toBe('a@b.c')
expect(loaded?.token_storage).toBe('keychain')
})
it('writes file with mode 0600', async () => {
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const info = await stat(join(dir, HOSTS_FILE_NAME))
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('rewrites permissive existing file with mode 0600', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 })
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const info = await stat(path)
expect(info.mode & 0o777).toBe(FILE_PERM)
})
it('atomic write: temp file does not survive on success', async () => {
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
const { readdir } = await import('node:fs/promises')
const entries = await readdir(dir)
expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0)
})
it('drops unknown top-level fields', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM })
const loaded = await loadHosts(dir)
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect((loaded as Record<string, unknown> | undefined)?.future_field).toBeUndefined()
})
it('throws on malformed YAML', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, ': : :\n', { mode: FILE_PERM })
await expect(loadHosts(dir)).rejects.toThrow()
})
it('throws when YAML contradicts schema', async () => {
const path = join(dir, HOSTS_FILE_NAME)
await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM })
await expect(loadHosts(dir)).rejects.toThrow()
})
it('produces YAML with stable keys', async () => {
await saveHosts(dir, {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_x' },
})
const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8')
expect(raw).toContain('current_host: cloud.dify.ai')
expect(raw).toContain('bearer: dfoa_x')
})
})

View File

@ -1,8 +1,10 @@
import type { Store } from '../store/store.js'
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { z } from 'zod'
import { BaseError } from '../errors/base.js'
import { ErrorCode } from '../errors/codes.js'
import { getHostStore, tokenKey } from '../store/manager.js'
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
export const HOSTS_FILE_NAME = 'hosts.yml'
const StorageModeSchema = z.enum(['keychain', 'file'])
export type StorageMode = z.infer<typeof StorageModeSchema>
@ -27,152 +29,72 @@ export const ExternalSubjectSchema = z.object({
})
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
export const AccountContextSchema = z.object({
account: AccountSchema,
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(),
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 AccountContext = z.infer<typeof AccountContextSchema>
export type HostsBundle = z.infer<typeof HostsBundleSchema>
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 async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
const path = join(dir, HOSTS_FILE_NAME)
let raw: string
try {
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return undefined
throw err
}
const parsed = yaml.load(raw)
return HostsBundleSchema.parse(parsed ?? {})
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
}
export class Registry {
private readonly data: RegistryData
private constructor(data: RegistryData) {
this.data = data
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
await mkdir(dir, { recursive: true, mode: DIR_PERM })
const validated = HostsBundleSchema.parse(bundle)
const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false })
const target = join(dir, HOSTS_FILE_NAME)
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`
try {
await writeFile(tmp, body, { mode: FILE_PERM })
await rename(tmp, target)
}
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 {
catch (err) {
try {
store.unset(tokenKey(active.host, active.email))
await unlink(tmp)
}
catch { /* best-effort */ }
this.remove(active.host, active.email)
this.save()
catch { /* tmp may not exist */ }
throw err
}
save(): void {
getHostStore().setTyped(RegistrySchema.parse(this.data))
const { chmod, stat } = await import('node:fs/promises')
try {
const info = await stat(target)
if ((info.mode & 0o777) !== FILE_PERM)
await chmod(target, FILE_PERM)
}
catch { /* best-effort */ }
}
function stripUndefined<T extends Record<string, unknown>>(input: T): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(input)) {
if (v === undefined)
continue
out[k] = v
}
return out
}

View File

@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const passwords = new Map<string, string>()
const setPassword = vi.fn()
const getPassword = vi.fn()
const deletePassword = vi.fn()
class FakeAsyncEntry {
private readonly key: string
constructor(service: string, username: string) {
this.key = `${service}::${username}`
}
async setPassword(value: string): Promise<void> {
setPassword(this.key, value)
passwords.set(this.key, value)
}
async getPassword(): Promise<string | undefined> {
getPassword(this.key)
return passwords.get(this.key)
}
async deletePassword(): Promise<boolean> {
deletePassword(this.key)
if (!passwords.has(this.key))
return false
passwords.delete(this.key)
return true
}
}
vi.mock('@napi-rs/keyring', () => ({
AsyncEntry: FakeAsyncEntry,
}))
const { KEYRING_SERVICE, KeyringBackend } = await import('./keyring-backend.js')
beforeEach(() => {
passwords.clear()
setPassword.mockClear()
getPassword.mockClear()
deletePassword.mockClear()
})
describe('KeyringBackend', () => {
it('uses service name "difyctl"', () => {
expect(KEYRING_SERVICE).toBe('difyctl')
})
it('returns undefined when no password is stored', async () => {
const k = new KeyringBackend()
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('round-trips put/get', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'dfoa_x')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_x')
})
it('keys by host::accountId', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
await k.put('cloud.dify.ai', 'acct-2', 'B')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('A')
expect(await k.get('cloud.dify.ai', 'acct-2')).toBe('B')
})
it('delete removes the entry', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
await k.delete('cloud.dify.ai', 'acct-1')
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('delete is a no-op for missing entries', async () => {
const k = new KeyringBackend()
await expect(k.delete('cloud.dify.ai', 'gone')).resolves.toBeUndefined()
})
it('list returns empty array (keyring does not enumerate)', async () => {
const k = new KeyringBackend()
await k.put('cloud.dify.ai', 'acct-1', 'A')
expect(await k.list('cloud.dify.ai')).toEqual([])
})
it('swallows getPassword exceptions and returns undefined', async () => {
const k = new KeyringBackend()
getPassword.mockImplementationOnce(() => {
throw new Error('NoEntry')
})
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
})
it('swallows delete exceptions', async () => {
const k = new KeyringBackend()
deletePassword.mockImplementationOnce(() => {
throw new Error('NoEntry')
})
await expect(k.delete('cloud.dify.ai', 'acct-1')).resolves.toBeUndefined()
})
it('lets put propagate exceptions (caller decides fallback)', async () => {
const k = new KeyringBackend()
setPassword.mockImplementationOnce(() => {
throw new Error('keyring locked')
})
await expect(k.put('cloud.dify.ai', 'acct-1', 'tok')).rejects.toThrow(/keyring locked/)
})
})

View File

@ -0,0 +1,35 @@
import type { TokenStore } from './store.js'
import { AsyncEntry } from '@napi-rs/keyring'
export const KEYRING_SERVICE = 'difyctl'
function username(host: string, accountId: string): string {
return `${host}::${accountId}`
}
export class KeyringBackend implements TokenStore {
async put(host: string, accountId: string, token: string): Promise<void> {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token)
}
async get(host: string, accountId: string): Promise<string | undefined> {
try {
const v = await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).getPassword()
return v ?? undefined
}
catch {
return undefined
}
}
async delete(host: string, accountId: string): Promise<void> {
try {
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword()
}
catch { /* missing entry is fine */ }
}
async list(_host: string): Promise<readonly string[]> {
return []
}
}

View File

@ -0,0 +1,75 @@
import type { TokenStore } from './store.js'
import { describe, expect, it, vi } from 'vitest'
import { selectStore } from './store.js'
function memBackend(label: string): TokenStore & { _label: string } {
const map = new Map<string, string>()
const k = (h: string, a: string) => `${h}::${a}`
return {
_label: label,
async put(h, a, t) { map.set(k(h, a), t) },
async get(h, a) { return map.get(k(h, a)) },
async delete(h, a) { map.delete(k(h, a)) },
async list() { return [] },
}
}
describe('selectStore', () => {
it('returns keychain when probe succeeds', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
})
it('falls back to file when keyring put throws', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
k.put = vi.fn().mockRejectedValue(new Error('locked'))
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when probe round-trip mismatches', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
k.get = vi.fn().mockResolvedValue('something-else')
const result = await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when keyring constructor throws', async () => {
const f = memBackend('file')
const result = await selectStore({
configDir: '/tmp/x',
factory: {
keyring: () => { throw new Error('no backend') },
file: () => f,
},
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('cleans up probe entry after successful probe', async () => {
const k = memBackend('keyring')
const f = memBackend('file')
await selectStore({
configDir: '/tmp/x',
factory: { keyring: () => k, file: () => f },
})
expect(await k.get('__difyctl_probe__', '__probe__')).toBeUndefined()
})
})

40
cli/src/auth/store.ts Normal file
View File

@ -0,0 +1,40 @@
import { FileBackend } from './file-backend.js'
import { KeyringBackend } from './keyring-backend.js'
export type TokenStore = {
put: (host: string, accountId: string, token: string) => Promise<void>
get: (host: string, accountId: string) => Promise<string | undefined>
delete: (host: string, accountId: string) => Promise<void>
list: (host: string) => Promise<readonly string[]>
}
export type StorageMode = 'keychain' | 'file'
export type SelectStoreOptions = {
readonly configDir: string
readonly factory?: {
readonly keyring?: () => TokenStore
readonly file?: (dir: string) => TokenStore
}
}
const PROBE_HOST = '__difyctl_probe__'
const PROBE_ACCOUNT = '__probe__'
const PROBE_VALUE = 'probe-v1'
export async function selectStore(opts: SelectStoreOptions): Promise<{ store: TokenStore, mode: StorageMode }> {
const fileFactory = opts.factory?.file ?? ((dir: string) => new FileBackend(dir))
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBackend())
try {
const k = keyringFactory()
await k.put(PROBE_HOST, PROBE_ACCOUNT, PROBE_VALUE)
const got = await k.get(PROBE_HOST, PROBE_ACCOUNT)
await k.delete(PROBE_HOST, PROBE_ACCOUNT)
if (got !== PROBE_VALUE)
throw new Error('keyring round-trip mismatch')
return { store: k, mode: 'keychain' }
}
catch {
return { store: fileFactory(opts.configDir), mode: 'file' }
}
}

View File

@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { platform } from '../sys/index.js'
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info.js'
@ -35,25 +35,18 @@ function metaInfoOnly(): AppMeta {
describe('app-info disk cache', () => {
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-'))
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})
it('round-trips an entry across reloads', async () => {
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const got = c2.get('http://localhost:9999', 'app-1')
expect(got).toBeDefined()
expect(got?.meta.info?.id).toBe('app-1')
@ -62,7 +55,7 @@ describe('app-info disk cache', () => {
it('isFresh respects TTL', async () => {
const now = new Date('2026-05-09T00:00:00Z')
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now })
await c.set('h', 'app-1', metaInfoOnly())
const r = c.get('h', 'app-1')
expect(r).toBeDefined()
@ -73,23 +66,23 @@ describe('app-info disk cache', () => {
})
it('keys by (host, app_id) — different hosts isolate', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h1', 'app-1', metaInfoOnly())
expect(c.get('h2', 'app-1')).toBeUndefined()
expect(c.get('h1', 'app-1')).toBeDefined()
})
it('delete removes entry from disk', async () => {
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c1.set('h', 'app-1', metaInfoOnly())
await c1.delete('h', 'app-1')
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c2.get('h', 'app-1')).toBeUndefined()
})
it('writes file with 0600 permission', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
@ -98,19 +91,19 @@ describe('app-info disk cache', () => {
})
it('missing cache file is not an error', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('corrupt cache file is treated as empty', async () => {
const { writeFile } = await import('node:fs/promises')
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
expect(c.get('h', 'app-1')).toBeUndefined()
})
it('updates same key in place (no growth)', async () => {
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await c.set('h', 'app-1', metaInfoOnly())
const slim: AppMeta = {
...metaInfoOnly(),

View File

@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js'
function nudgeStorePath(dir: string): string {
@ -15,28 +15,21 @@ const HOST = 'https://cloud.dify.ai'
describe('NudgeStore', () => {
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})
it('canWarn=true when no prior record exists', async () => {
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
expect(store.canWarn(HOST)).toBe(true)
})
it('canWarn=false within the silence window, true past it', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await store.markWarned(HOST)
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
@ -44,7 +37,7 @@ describe('NudgeStore', () => {
it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await store.markWarned(HOST)
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
expect(store.canWarn(HOST, pastClock)).toBe(false)
@ -52,22 +45,22 @@ describe('NudgeStore', () => {
it('markWarned persists across store reloads', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
expect(s2.canWarn(HOST)).toBe(false)
})
it('treats a corrupt cache file as empty', async () => {
const path = nudgeStorePath(dir)
await writeCacheFile(path, '{ not valid json')
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
expect(store.canWarn(HOST)).toBe(true)
})
it('writes ISO timestamps under warned/<host> on disk', async () => {
const t = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = yaml.load(raw) as Record<string, unknown>
@ -79,11 +72,11 @@ describe('NudgeStore', () => {
// warns about a different host. Without merge-on-write the second writer
// would clobber the first.
const t = new Date('2026-05-19T12:00:00.000Z')
const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const a = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
const b = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
await a.markWarned('https://a.example')
await b.markWarned('https://b.example')
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
expect(reread.canWarn('https://a.example')).toBe(false)
expect(reread.canWarn('https://b.example')).toBe(false)
})

View File

@ -1,17 +1,18 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../auth/hosts.js'
import type { HostsBundle } 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 { notLoggedInError, Registry } from '../../auth/hosts.js'
import { loadHosts } 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 { resolveConfigDir } from '../../store/dir.js'
import { realStreams } from '../../sys/io/streams'
import { hostWithScheme } from '../../util/host.js'
import { versionInfo } from '../../version/info.js'
@ -19,12 +20,11 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
import { resolveRetryAttempts } from './global-flags.js'
export type AuthedContext = {
readonly reg: Registry
readonly active: ActiveContext
readonly store: Store
readonly bundle: HostsBundle
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
readonly configDir: string
readonly cache?: AppInfoCache
}
@ -38,31 +38,30 @@ export async function buildAuthedContext(
cmd: Pick<Command, 'error'>,
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const reg = Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
const bundle = await loadHosts(configDir)
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
}
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 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 cache = opts.withCache === true ? await loadAppInfoCache() : undefined
await runCompatNudge({ host, io })
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() })
return { bundle, http, host, io, configDir, cache }
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -1,50 +1,52 @@
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 { ActiveContext } from '../../../../auth/hosts.js'
import type { Key, Store } from '../../../../store/store.js'
import { mkdtemp, rm } from 'node:fs/promises'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { TokenStore } from '../../../../auth/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
import { Registry } from '../../../../auth/hosts.js'
import { saveHosts } from '../../../../auth/hosts.js'
import { createClient } from '../../../../http/client.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'
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
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix))
}
}
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' },
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
return {
current_host: host,
scheme: 'http',
token_storage: 'file',
token_id: tokenId,
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_id: tokenId,
})
reg.setHost(host)
reg.setAccount(email)
const active = reg.resolveActive()!
return { reg, active }
}
}
describe('runDevicesList', () => {
@ -59,7 +61,7 @@ describe('runDevicesList', () => {
it('table: marks current with *', async () => {
const io = bufferStreams()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesList({ io, tokenId: 'tok-1', http })
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
const out = io.outBuf()
expect(out).toContain('DEVICE')
expect(out).toContain('difyctl on laptop')
@ -72,29 +74,30 @@ describe('runDevicesList', () => {
it('json: emits PaginationEnvelope unchanged', async () => {
const io = bufferStreams()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
await runDevicesList({ io, bundle: bundleFor(mock.url), 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', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
@ -102,12 +105,12 @@ describe('runDevicesRevoke', () => {
it('exact device_label: revokes one + leaves local creds', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
reg.save()
const b = bundleFor(mock.url, 'tok-1')
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
expect(store.entries.size).toBe(1)
})
@ -115,30 +118,30 @@ describe('runDevicesRevoke', () => {
it('exact id: revokes one', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
it('substring: unique match revokes', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
it('substring: ambiguous throws', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -146,10 +149,10 @@ describe('runDevicesRevoke', () => {
it('no match throws', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -157,33 +160,31 @@ describe('runDevicesRevoke', () => {
it('--all: revokes everything except current', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, all: true })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
it('revoking current session clears token and removes context from registry', async () => {
it('revoking current id clears local creds', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
reg.save()
const b = bundleFor(mock.url, 'tok-1')
await store.put(b.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
const saved = Registry.load()
expect(saved?.hosts[mock.url]).toBeUndefined()
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
})
it('no target + no --all: throws UsageMissingArg', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,9 +1,12 @@
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { ActiveContext, Registry } from '../../../../auth/hosts.js'
import type { Store } from '../../../../store/store.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { TokenStore } from '../../../../auth/store.js'
import type { IOStreams } from '../../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
import { BaseError } from '../../../../errors/base.js'
import { ErrorCode } from '../../../../errors/codes.js'
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
@ -12,7 +15,7 @@ import { runWithSpinner } from '../../../../sys/io/spinner.js'
export type DevicesListOptions = {
readonly io: IOStreams
readonly tokenId: string
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
readonly json?: boolean
readonly page?: number
@ -21,6 +24,7 @@ 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)
@ -35,7 +39,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
return
}
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
@ -68,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
}
export type DevicesRevokeOptions = {
readonly configDir: string
readonly io: IOStreams
readonly reg: Registry
readonly active: ActiveContext
readonly store: Store
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
readonly store: TokenStore
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -80,6 +84,7 @@ 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,
@ -90,7 +95,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, opts.active.ctx.token_id ?? '')
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
if (ids.length === 0) {
opts.io.out.write('no sessions to revoke\n')
return
@ -100,11 +105,22 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
await sessions.revoke(id)
if (selfHit)
opts.reg.forget(opts.active, opts.store)
await clearLocal(opts.configDir, b, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
function requireLogin(b: HostsBundle | undefined): HostsBundle {
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
}
return b
}
export type PickResult = {
ids: readonly string[]
selfHit: boolean
@ -162,3 +178,18 @@ function renderTable(rows: readonly SessionRow[], currentId: string): string {
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
}
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
try {
await store.delete(bundle.current_host, accountId)
}
catch { /* best-effort */ }
try {
await unlink(join(configDir, HOSTS_FILE_NAME))
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}

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,
tokenId: ctx.active.ctx.token_id ?? '',
bundle: ctx.bundle,
http: ctx.http,
json: flags.json,
page: flags.page,

View File

@ -1,3 +1,4 @@
import { selectStore } from '../../../../auth/store.js'
import { Args, Flags } from '../../../../framework/flags.js'
import { DifyCommand } from '../../../_shared/dify-command.js'
import { httpRetryFlag } from '../../../_shared/global-flags.js'
@ -24,12 +25,13 @@ export default class DevicesRevoke extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { args, flags } = this.parse(DevicesRevoke, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const { store } = await selectStore({ configDir: ctx.configDir })
await runDevicesRevoke({
configDir: ctx.configDir,
io: ctx.io,
reg: ctx.reg,
active: ctx.active,
store: ctx.store,
bundle: ctx.bundle,
http: ctx.http,
store,
target: args.target,
all: flags.all,
yes: flags.yes,

View File

@ -1,4 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runLogin } from './login.js'
@ -30,6 +31,7 @@ export default class Login extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Login, argv)
await runLogin({
configDir: resolveConfigDir(),
io: realStreams(),
host: flags.host,
noBrowser: flags['no-browser'],

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { Key, Store } from '../../../store/store.js'
import type { TokenStore } from '../../../auth/store.js'
import type { Clock } from './device-flow.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { createClient } from '../../../http/client.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogin } from './login.js'
@ -20,38 +18,38 @@ const noopClock: Clock = {
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
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
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
describe('runLogin', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
@ -59,7 +57,8 @@ describe('runLogin', () => {
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -70,17 +69,16 @@ describe('runLogin', () => {
clock: noopClock,
browserOpener: noopBrowser,
})
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')
expect(bundle.tokens?.bearer).toBe('dfoa_test')
expect(bundle.account?.email).toBe('tester@dify.ai')
expect(bundle.workspace?.id).toBe('ws-1')
expect(bundle.available_workspaces).toHaveLength(2)
const stored = await store.get(bundle.current_host, 'acct-1')
expect(stored).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsRaw).toContain('current_host:')
expect(hostsRaw).toContain('tester@dify.ai')
expect(hostsRaw).not.toContain('dfoa_test')
expect(hostsRaw).not.toContain('bearer')
expect(io.outBuf()).toContain('Logged in to')
expect(io.outBuf()).toContain('tester@dify.ai')
@ -92,7 +90,8 @@ describe('runLogin', () => {
mock.setScenario('sso')
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -103,11 +102,10 @@ describe('runLogin', () => {
clock: noopClock,
browserOpener: noopBrowser,
})
const active = reg.resolveActive()
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
expect(active?.ctx.account.email).toBe('')
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
expect(bundle.tokens?.bearer).toBe('dfoe_test')
expect(bundle.account).toBeUndefined()
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
expect(io.outBuf()).toContain('external SSO')
expect(io.outBuf()).toContain('sso@dify.ai')
})
@ -117,6 +115,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -136,6 +135,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -148,28 +148,11 @@ 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()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -186,6 +169,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,

View File

@ -1,37 +1,35 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/store.js'
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
import type { Clock } from './device-flow.js'
import * as os from 'node:os'
import * as readline from 'node:readline'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { Registry } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { saveHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.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'
export type LoginOptions = {
readonly configDir: string
readonly io: IOStreams
readonly host?: string
readonly noBrowser?: boolean
readonly insecure?: boolean
readonly deviceLabel?: string
readonly store?: { readonly store: Store, readonly mode: StorageMode }
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
readonly clock?: Clock
}
export async function runLogin(opts: LoginOptions): Promise<Registry> {
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const insecure = opts.insecure ?? false
@ -59,44 +57,22 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
}
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
let success: PollSuccess
try {
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
}
finally {
spinner.stop()
}
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const storeBundle = opts.store ?? getTokenStore()
const display = bareHost(host)
const email = accountEmail(success)
const ctx = contextFromSuccess(success)
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
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()
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
await saveHosts(opts.configDir, bundle)
renderLoggedIn(opts.io.out, cs, host, success)
return reg
return bundle
}
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
let raw = opts.host?.trim() ?? ''
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\'',
})
}
if (raw === '')
raw = await promptHost(opts.io)
}
return resolveHost({ raw, insecure })
}
@ -124,7 +100,7 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
const display = bareHost(host)
if (s.account && s.account.email !== '') {
if (s.account !== undefined && s.account.email !== '') {
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
const ws = findDefaultWorkspace(s)
if (ws !== undefined)
@ -147,43 +123,50 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
return s.workspaces?.find(w => w.id === s.default_workspace_id)
}
function accountEmail(s: PollSuccess): string {
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
if (email === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'account has no email; cannot store credential',
hint: 'this Dify instance returned no email for the signed-in subject',
})
}
return email
}
function contextFromSuccess(s: PollSuccess): AccountContext {
const ctx: AccountContext = {
account: s.account
? { id: s.account.id, email: s.account.email, name: s.account.name }
: { id: '', email: '', name: '' },
token_id: s.token_id,
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (!s.account || s.account.id === '')) {
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
ctx.workspace = def
if (s.workspaces !== undefined && s.workspaces.length > 0) {
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
}
return ctx
}
function applyScheme(reg: Registry, display: string, host: string): void {
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:')
reg.setScheme(display, u.protocol.replace(':', ''))
scheme = u.protocol.replace(':', '')
}
catch { /* keep scheme unset */ }
catch { /* keep undefined */ }
const bundle: HostsBundle = {
current_host: display,
scheme,
token_storage: mode,
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account !== undefined) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (s.account === undefined || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',
}
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
bundle.workspace = def
if (s.workspaces !== undefined && s.workspaces.length > 0) {
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
id: w.id,
name: w.name,
role: w.role,
}))
}
return bundle
}
function accountKey(b: HostsBundle): string {
if (b.account?.id !== undefined && b.account.id !== '')
return b.account.id
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
return b.external_subject.email
return 'default'
}

View File

@ -1,7 +1,8 @@
import type { KyInstance } from 'ky'
import { Registry } from '../../../auth/hosts.js'
import { loadHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { createClient } from '../../../http/client.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { realStreams } from '../../../sys/io/streams'
import { hostWithScheme } from '../../../util/host.js'
@ -17,21 +18,23 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const io = realStreams()
const reg = Registry.load()
const active = reg.resolveActive()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const { store } = await selectStore({ configDir })
let http: KyInstance | undefined
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 })
}
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
http = createClient({
host: hostWithScheme(bundle.current_host, bundle.scheme),
bearer: bundle.tokens.bearer,
retryAttempts: 0,
})
}
const io = realStreams()
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ io, reg, http }),
() => runLogout({ configDir, io, bundle, http, store }),
)
}
}

View File

@ -1,64 +1,143 @@
import type { Key, Store } from '../../../store/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.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) }
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
async put(host: string, accountId: string, token: string): Promise<void> {
this.entries.set(`${host}::${accountId}`, token)
}
async get(host: string, accountId: string): Promise<string | undefined> {
return this.entries.get(`${host}::${accountId}`)
}
async delete(host: string, accountId: string): Promise<void> {
this.entries.delete(`${host}::${accountId}`)
}
async list(host: string): Promise<readonly string[]> {
const prefix = `${host}::`
return Array.from(this.entries.keys())
.filter(k => k.startsWith(prefix))
.map(k => k.slice(prefix.length))
}
}
function fixtureBundle(host: string): HostsBundle {
return {
current_host: host,
scheme: 'http',
token_storage: 'file',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
describe('runLogout', () => {
let dir: string
let prev: string | undefined
let mock: DifyMock
let configDir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
})
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 })
await mock.stop()
await rm(configDir, { recursive: true, force: true })
})
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 () => {
it('happy: revokes server side, clears local store + hosts.yml', async () => {
const io = bufferStreams()
const store = new MemStore()
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')
const bundle = fixtureBundle(mock.url)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
expect(io.outBuf()).toContain('Logged out of')
expect(io.errBuf()).toBe('')
})
it('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)
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
await saveHosts(configDir, bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
expect(io.outBuf()).toContain('Logged out of')
})
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
await saveHosts(configDir, bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ configDir, io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
})
it('preserves unrelated files in configDir', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
await saveHosts(configDir, bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ configDir, io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')
})
})

View File

@ -1,46 +1,70 @@
import type { KyInstance } from 'ky'
import type { Registry } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { TokenStore } from '../../../auth/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import { unlink } from 'node:fs/promises'
import { join } from 'node:path'
import { AccountSessionsClient } from '../../../api/account-sessions.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type LogoutOptions = {
readonly configDir: string
readonly io: IOStreams
readonly reg: Registry
readonly bundle: HostsBundle | undefined
readonly http?: KyInstance
/** Optional override for tests; production resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
}
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
export async function runLogout(opts: LogoutOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const reg = opts.reg
const active = reg.requireActive()
const store = opts.store ?? getTokenStore().store
const bearer = store.get(tokenKey(active.host, active.email))
const bundle = opts.bundle
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
}
let revokeWarning = ''
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
try {
await new AccountSessionsClient(opts.http).revokeSelf()
const sessions = new AccountSessionsClient(opts.http)
await sessions.revokeSelf()
}
catch (err) {
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
}
}
reg.forget(active, store)
await clearLocal(opts.configDir, bundle, opts.store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
}
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
function revokeAllowed(bearer: string): boolean {
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
}
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
try {
await store.delete(bundle.current_host, accountId)
}
catch { /* best-effort */ }
const hostsPath = join(configDir, HOSTS_FILE_NAME)
try {
await unlink(hostsPath)
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}

View File

@ -1,5 +1,6 @@
import { Registry } from '../../../auth/hosts.js'
import { loadHosts } from '../../../auth/hosts.js'
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runStatus } from './status.js'
@ -20,7 +21,8 @@ export default class Status extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Status, argv)
const reg = Registry.load()
await runStatus({ io: realStreams(), reg, verbose: flags.verbose, json: flags.json })
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
}
}

View File

@ -1,65 +1,49 @@
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 accountReg(): Registry {
return Registry.from({
token_storage: 'keychain',
function accountBundle(): HostsBundle {
return {
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',
},
},
},
},
})
token_storage: 'keychain',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
function ssoReg(): Registry {
return Registry.from({
token_storage: 'file',
function ssoBundle(): HostsBundle {
return {
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' },
},
},
},
},
})
token_storage: 'file',
token_id: 'tok-sso-1',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
}
describe('runStatus', () => {
it('logged-out: prints message + throws NotLoggedIn', async () => {
const io = bufferStreams()
await expect(runStatus({ io, reg: Registry.empty() })).rejects.toThrow(/not logged in/)
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
expect(io.outBuf()).toContain('Not logged in')
})
it('logged-out json: emits {logged_in: false}', async () => {
const io = bufferStreams()
await expect(runStatus({ io, reg: Registry.empty(), json: true })).rejects.toThrow(/not logged in/)
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
})
it('account: human compact', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg() })
await runStatus({ io, bundle: accountBundle() })
const out = io.outBuf()
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
expect(out).toContain('Workspace: Default')
@ -68,7 +52,7 @@ describe('runStatus', () => {
it('account verbose: shows ids + storage + workspace count', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg(), verbose: true })
await runStatus({ io, bundle: accountBundle(), verbose: true })
const out = io.outBuf()
expect(out).toContain('cloud.dify.ai')
expect(out).toContain('Account:')
@ -76,12 +60,11 @@ 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, reg: ssoReg() })
await runStatus({ io, bundle: ssoBundle() })
const out = io.outBuf()
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
expect(out).toContain('apps:run')
@ -89,7 +72,7 @@ describe('runStatus', () => {
it('account json: matches schema with workspace + workspace count', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg(), json: true })
await runStatus({ io, bundle: accountBundle(), 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)
@ -101,7 +84,7 @@ describe('runStatus', () => {
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
const io = bufferStreams()
await runStatus({ io, reg: ssoReg(), json: true })
await runStatus({ io, bundle: ssoBundle(), 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,94 +1,91 @@
import type { AccountContext, Registry } from '../../../auth/hosts.js'
import type { HostsBundle } 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 reg: Registry
readonly bundle: HostsBundle | undefined
readonly verbose?: boolean
readonly json?: boolean
}
export async function runStatus(opts: StatusOptions): Promise<void> {
const reg = opts.reg
const active = reg.resolveActive()
if (active === undefined) {
if (opts.json === true)
const bundle = opts.bundle
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
else
}
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(active.host, active.ctx, reg.token_storage)}\n`)
opts.io.out.write(`${renderJson(bundle)}\n`)
return
}
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))
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
}
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
function renderHuman(b: HostsBundle, verbose: boolean): string {
const lines: string[] = []
const sub = ctx.external_subject
if (!verbose) {
if (sub !== undefined) {
if (b.external_subject !== undefined) {
const sub = b.external_subject
lines.push(sub.issuer !== ''
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
: `Logged in to ${host} as ${sub.email} (via SSO)`)
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
lines.push(' Scope: apps:run')
return `${lines.join('\n')}\n`
}
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}`)
const acc = b.account ?? { id: '', email: '', name: '' }
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
if (b.workspace?.name !== undefined && b.workspace.name !== '')
lines.push(` Workspace: ${b.workspace.name}`)
lines.push(' Session: Dify account — full access')
return `${lines.join('\n')}\n`
}
if (sub !== undefined) {
lines.push(host)
if (b.external_subject !== undefined) {
const sub = b.external_subject
lines.push(b.current_host)
lines.push(sub.issuer !== ''
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
: ` Subject: ${sub.email} (external SSO)`)
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
lines.push(` Storage: ${storage}`)
lines.push(` Storage: ${b.token_storage}`)
return `${lines.join('\n')}\n`
}
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`)
const acc = b.account ?? { id: '', email: '', name: '' }
lines.push(b.current_host)
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
if (b.workspace?.id !== undefined && b.workspace.id !== '')
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
lines.push(' Session: Dify account — full access (scope: full)')
lines.push(` Storage: ${storage}`)
lines.push(` Storage: ${b.token_storage}`)
return `${lines.join('\n')}\n`
}
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}`)
}
function renderJson(b: HostsBundle): string {
const out: Record<string, unknown> = {
host: b.current_host,
logged_in: true,
storage: b.token_storage,
}
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) {
if (b.external_subject !== undefined) {
out.subject_type = 'external_sso'
out.subject_email = ctx.external_subject.email
out.subject_issuer = ctx.external_subject.issuer
out.subject_email = b.external_subject.email
out.subject_issuer = b.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
else if (b.account !== undefined) {
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
}
out.available_workspaces_count = b.available_workspaces?.length ?? 0
}
return JSON.stringify(out, null, 2)
}

View File

@ -1,5 +1,6 @@
import { Registry } from '../../../auth/hosts.js'
import { loadHosts } from '../../../auth/hosts.js'
import { Flags } from '../../../framework/flags.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runWhoami } from './whoami.js'
@ -18,7 +19,8 @@ export default class Whoami extends DifyCommand {
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(Whoami, argv)
const reg = Registry.load()
await runWhoami({ io: realStreams(), reg, json: flags.json })
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runWhoami({ io: realStreams(), bundle, json: flags.json })
}
}

View File

@ -1,82 +1,68 @@
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 accountReg(): Registry {
return Registry.from({
token_storage: 'file',
function accountBundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
} } },
})
}
function ssoReg(): Registry {
return Registry.from({
token_storage: 'file',
current_host: 'cloud.dify.ai',
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
'sso@dify.ai': {
account: { email: 'sso@dify.ai', name: '' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
},
} } },
})
token_storage: 'keychain',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
}
}
describe('runWhoami', () => {
it('throws NotLoggedIn when no active context', async () => {
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
})
it('prints email + name for an account', async () => {
it('logged-out: throws NotLoggedIn', async () => {
const io = bufferStreams()
await runWhoami({ io, reg: accountReg() })
expect(io.outBuf()).toContain('a@b.c')
expect(io.outBuf()).toContain('Ann')
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
})
it('account human: emits "email (name)"', async () => {
const io = bufferStreams()
await runWhoami({ io, reg: accountReg() })
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
await runWhoami({ io, bundle: accountBundle() })
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
})
it('account human, no name: emits email only', async () => {
const io = bufferStreams()
const 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' })
const b = accountBundle()
b.account!.name = ''
await runWhoami({ io, bundle: b })
expect(io.outBuf()).toBe('tester@dify.ai\n')
})
it('account json: emits {id, email, name}', async () => {
const io = bufferStreams()
await runWhoami({ io, reg: accountReg(), json: true })
await runWhoami({ io, bundle: accountBundle(), json: true })
expect(JSON.parse(io.outBuf())).toEqual({
id: 'acct-1',
email: 'a@b.c',
name: 'Ann',
email: 'tester@dify.ai',
name: 'Test Tester',
})
})
it('sso human: emits email + issuer', async () => {
const io = bufferStreams()
await runWhoami({ io, reg: ssoReg() })
const b: HostsBundle = {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
await runWhoami({ io, bundle: b })
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
})
it('sso json: emits {subject_type, email, issuer}', async () => {
const io = bufferStreams()
await runWhoami({ io, reg: ssoReg(), json: true })
const b: HostsBundle = {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
await runWhoami({ io, bundle: b, json: true })
expect(JSON.parse(io.outBuf())).toEqual({
subject_type: 'external_sso',
email: 'sso@dify.ai',

View File

@ -1,31 +1,46 @@
import type { Registry } from '../../../auth/hosts.js'
import type { HostsBundle } 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 reg: Registry
readonly bundle: HostsBundle | undefined
readonly json?: boolean
}
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
const active = opts.reg.requireActive()
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 sub = active.ctx.external_subject
if (sub !== undefined) {
if (b.external_subject !== undefined) {
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
opts.io.out.write(`${JSON.stringify({
subject_type: 'external_sso',
email: b.external_subject.email,
issuer: b.external_subject.issuer,
})}\n`)
return
}
const sub = b.external_subject
opts.io.out.write(sub.issuer !== ''
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
: `${sub.email} (external SSO)\n`)
return
}
const acc = active.ctx.account
const acc = b.account ?? { id: '', email: '', name: '' }
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
return
}
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
opts.io.out.write(acc.name !== ''
? `${acc.email} (${acc.name})\n`
: `${acc.email}\n`)
}

View File

@ -1,49 +1,43 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigGet } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigGet', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-get-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('returns set value with trailing newline', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
it('returns set value with trailing newline', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('yaml\n')
})
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('\n')
})
it('throws BaseError(config_invalid_key) on unknown key', () => {
let caught: unknown
try {
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,12 +45,13 @@ describe('runConfigGet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('returns numeric limit as string', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { limit: 75 },
})
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
it('returns numeric limit as string', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n limit: 75\n',
'utf8',
)
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.limit' })
expect(out).toBe('75\n')
})
})

View File

@ -1,8 +1,7 @@
import { join } from 'node:path'
import { raw } from '../../../framework/output.js'
import { resolveConfigDir } from '../../../store/dir.js'
import { CONFIG_FILE_NAME } from '../../../store/manager.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runConfigPath } from './run.js'
export default class ConfigPath extends DifyCommand {
static override description = 'Print the resolved config.yml path'
@ -13,8 +12,6 @@ export default class ConfigPath extends DifyCommand {
async run(argv: string[]) {
this.parse(ConfigPath, argv)
return raw(
join(resolveConfigDir(), CONFIG_FILE_NAME),
)
return raw(runConfigPath({ dir: resolveConfigDir() }))
}
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { runConfigPath } from './run.js'
describe('runConfigPath', () => {
it('joins dir and config.yml with trailing newline', () => {
const out = runConfigPath({ dir: '/tmp/x' })
expect(out).toBe('/tmp/x/config.yml\n')
})
it('handles trailing slash on dir', () => {
const out = runConfigPath({ dir: '/tmp/x/' })
expect(out).toBe('/tmp/x/config.yml\n')
})
})

View File

@ -0,0 +1,10 @@
import { join } from 'node:path'
import { FILE_NAME } from '../../../config/schema.js'
export type RunConfigPathOptions = {
readonly dir: string
}
export function runConfigPath(opts: RunConfigPathOptions): string {
return `${join(opts.dir, FILE_NAME)}\n`
}

View File

@ -1,46 +1,35 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode, ExitCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigSet } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigSet', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-set-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('persists the value and returns "set k = v\\n"', () => {
const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
it('writes config.yml and returns "set k = v\\n"', async () => {
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
expect(out).toBe('set defaults.format = json\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: json')
})
it('rejects invalid format value with config_invalid_value', () => {
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -51,7 +40,7 @@ describe('runConfigSet', () => {
it('rejects unknown key with config_invalid_key', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -59,22 +48,18 @@ describe('runConfigSet', () => {
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('preserves prior keys when setting a new one', () => {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('yaml')
expect(r.config.defaults.limit).toBe(40)
}
it('preserves prior keys when setting a new one', async () => {
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'yaml' })
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: '40' })
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('format: yaml')
expect(raw).toContain('limit: 40')
})
it('exit code for invalid value is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,7 +70,7 @@ describe('runConfigSet', () => {
it('exit code for unknown key is Usage (2)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -96,7 +81,7 @@ describe('runConfigSet', () => {
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
let caught: unknown
try {
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,61 +1,48 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../../../config/config-loader.js'
import { beforeEach, describe, expect, it } from 'vitest'
import { FILE_NAME } from '../../../config/schema.js'
import { isBaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigUnset } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigUnset', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
})
it('clears the requested key, leaves others intact', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 25 },
})
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('clears the requested key, leaves others intact', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 25\n',
'utf8',
)
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).not.toBe('json')
expect(r.config.defaults.limit).toBe(25)
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).not.toContain('format:')
expect(raw).toContain('limit: 25')
})
it('is a no-op (writes empty config) when key was already unset', () => {
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
it('is a no-op (writes empty config) when key was already unset', async () => {
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
expect(out).toBe('unset defaults.format\n')
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toContain('schema_version: 1')
})
it('rejects unknown key', () => {
let caught: unknown
try {
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,69 +1,67 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { getConfigurationStore } from '../../../store/manager.js'
import { FILE_NAME } from '../../../config/schema.js'
import { YamlStore } from '../../../store/store.js'
import { runConfigView } from './run.js'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('runConfigView', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-view-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
// tmpdir cleanup is best-effort
})
it('text format: empty config returns empty string', () => {
const out = runConfigView({ store: getConfigurationStore() })
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('')
})
it('text format: emits "key = value" lines for set keys only', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 50 },
state: { current_app: 'app-1' },
})
const out = runConfigView({ store: getConfigurationStore() })
it('text format: emits "key = value" lines for set keys only', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
})
it('text format: skips unset keys', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'yaml' },
})
const out = runConfigView({ store: getConfigurationStore() })
it('text format: skips unset keys', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: yaml\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir) })
expect(out).toBe('defaults.format = yaml\n')
expect(out).not.toContain('defaults.limit')
expect(out).not.toContain('state.current_app')
})
it('json format: empty config returns "{}\\n"', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out).toBe('{}\n')
})
it('json format: defaults.limit is numeric, others are strings', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'table', limit: 100 },
state: { current_app: 'app-x' },
})
const out = runConfigView({ store: getConfigurationStore(), json: true })
it('json format: defaults.limit is numeric, others are strings', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n',
'utf8',
)
const out = runConfigView({ store: makeStore(dir), json: true })
const parsed = JSON.parse(out) as Record<string, unknown>
expect(parsed['defaults.format']).toBe('table')
expect(parsed['defaults.limit']).toBe(100)
@ -71,7 +69,7 @@ describe('runConfigView', () => {
})
it('json format: trailing newline matches Go encoder.Encode', () => {
const out = runConfigView({ store: getConfigurationStore(), json: true })
const out = runConfigView({ store: makeStore(dir), json: true })
expect(out.endsWith('\n')).toBe(true)
})
})

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runCreateMember } from './run.js'
@ -24,7 +24,7 @@ export default class CreateMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
}
async run(argv: string[]) {
@ -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 },
{ active: ctx.active, http: ctx.http, io: ctx.io },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runCreateMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
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' }],
},
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' }],
}
}
@ -36,7 +35,7 @@ describe('runCreateMember', () => {
const result = await runCreateMember(
{ email: 'new@example.com', role: 'normal' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -61,7 +60,7 @@ describe('runCreateMember', () => {
runCreateMember(
{ email: 'new@example.com', role: 'owner' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -77,7 +76,7 @@ describe('runCreateMember', () => {
runCreateMember(
{ email: '', role: 'normal' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -92,7 +91,7 @@ describe('runCreateMember', () => {
await runCreateMember(
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'),
active: deps.active,
bundle: deps.bundle,
})
const response = await runWithSpinner(

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDeleteMember } from './run.js'
@ -23,7 +23,7 @@ export default class DeleteMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
}
@ -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 },
{ active: ctx.active, http: ctx.http, io: ctx.io },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runDeleteMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
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' }],
},
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' }],
}
}
@ -28,7 +27,7 @@ describe('runDeleteMember', () => {
const result = await runDeleteMember(
{ memberId: 'acct-2' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -46,7 +45,7 @@ describe('runDeleteMember', () => {
await runDeleteMember(
{ memberId: 'acct-2', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -61,7 +60,7 @@ describe('runDeleteMember', () => {
runDeleteMember(
{ memberId: '' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'),
active: deps.active,
bundle: deps.bundle,
})
if (!opts.yes && io.isErrTTY) {

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDescribeApp } from './run.js'
@ -20,7 +20,7 @@ export default class DescribeApp extends DifyCommand {
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }),
}
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
format,
data: await runDescribeApp(
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ bundle: ctx.bundle, 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -8,49 +8,41 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../cache/app-info.js'
import { formatted, stringifyOutput } from '../../../framework/output.js'
import { createClient } from '../../../http/client.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { runDescribeApp } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
host: 'http://localhost',
email: 't@d.ai',
ctx: {
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
current_host: 'http://localhost',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
describe('runDescribeApp', () => {
let mock: DifyMock
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-'))
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const data = await runDescribeApp(
opts,
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
}
@ -90,16 +82,16 @@ describe('runDescribeApp', () => {
})
it('refresh: bypasses cache', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runDescribeApp(
{ appId: 'app-1' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
const before = cache.get(mock.url, 'app-1')
expect(before).toBeDefined()
await runDescribeApp(
{ appId: 'app-1', refresh: true },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
const after = cache.get(mock.url, 'app-1')
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
@ -113,7 +105,7 @@ describe('runDescribeApp', () => {
await expect(runDescribeApp(
{ appId: 'nope' },
{
active: active(),
bundle: bundle(),
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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const io = deps.io ?? nullStreams()

View File

@ -1,6 +1,6 @@
import type { AppMode } from '@dify/contracts/api/openapi/types.gen'
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat, table } from '../../../framework/output.js'
import { table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetApp } from './run.js'
@ -42,7 +42,7 @@ export default class GetApp extends DifyCommand {
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
name: flags.name,
tag: flags.tag,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
}, { bundle: ctx.bundle, 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { stringifyOutput, table } from '../../../framework/output.js'
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
import { AppListOutput } from './handlers.js'
import { runGetApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
const baseBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
}
describe('runGetApp', () => {
@ -37,7 +36,7 @@ describe('runGetApp', () => {
}
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
const result = await runGetApp(opts, { active: baseActive, http: http() })
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
return stringifyOutput(table({
format: opts.format ?? '',
data: result.data,
@ -135,11 +134,7 @@ describe('runGetApp', () => {
})
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
const minimal: ActiveContext = {
host: 'h',
email: 'x@x.com',
ctx: { account: { email: 'x@x.com', name: 'X' } },
}
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
await expect(runGetApp({}, { bundle: 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'), active: deps.active })
const wsName = workspaceNameForId(deps.active, wsId)
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const wsName = workspaceNameForId(deps.bundle, wsId)
const desc = await apps.describe(opts.appId, wsId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
return apps.list({
workspaceId: wsId,
page,
@ -111,13 +111,12 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
}
}
function workspaceNameForId(active: ActiveContext, id: string): string {
function workspaceNameForId(b: HostsBundle, id: string): string {
if (id === '')
return ''
const ctx = active.ctx
if (ctx.workspace?.id === id)
return ctx.workspace.name
for (const w of ctx.available_workspaces ?? []) {
if (b.workspace?.id === id)
return b.workspace.name
for (const w of b.available_workspaces ?? []) {
if (w.id === id)
return w.name
}

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { OutputFormat, table } from '../../../framework/output.js'
import { table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetMember } from './run.js'
@ -23,7 +23,7 @@ export default class GetMember extends DifyCommand {
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
limitRaw: flags.limit,
format,
},
{ active: ctx.active, http: ctx.http, io: ctx.io },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return table({ format, data: result.data })
}

View File

@ -1,19 +1,18 @@
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runGetMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
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' }],
},
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' }],
}
}
@ -38,7 +37,7 @@ describe('runGetMember', () => {
const r = await runGetMember(
{},
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -55,7 +54,7 @@ describe('runGetMember', () => {
const r = await runGetMember(
{ workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -70,7 +69,7 @@ describe('runGetMember', () => {
await runGetMember(
{ page: 3, limitRaw: '50' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -79,20 +78,14 @@ describe('runGetMember', () => {
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
})
it('marks no row when active context has no account id', async () => {
it('marks no row when bundle has no account id', async () => {
const client = fakeClient(env)
const a: ActiveContext = {
host: 'cloud.dify.ai',
email: 'me@example.com',
ctx: {
account: { id: '', email: '', name: '' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
},
}
const b = bundle()
b.account = { id: '', email: '', name: '' }
const r = await runGetMember(
{},
{
active: a,
bundle: b,
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -103,16 +96,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(
{},
{
active: noWs,
bundle: {
current_host: '',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: '', name: '' },
},
http: {} as KyInstance,
io: bufferStreams(),
envLookup: () => undefined,
@ -139,7 +132,7 @@ describe('MemberListOutput shape', () => {
const r = await runGetMember(
{},
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'),
active: deps.active,
bundle: deps.bundle,
})
const limit = resolveLimit(opts.limitRaw, env)
@ -50,7 +50,7 @@ export async function runGetMember(
() => factory(deps.http).list(wsId, { page, limit }),
)
const callerId = deps.active.ctx.account?.id ?? ''
const callerId = deps.bundle.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

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { OutputFormat, raw, table } from '../../../framework/output.js'
import { raw, table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetWorkspace } from './run.js'
@ -15,14 +15,14 @@ export default class GetWorkspace extends DifyCommand {
static override flags = {
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
const { flags } = this.parse(GetWorkspace, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { stringifyOutput, table } from '../../../framework/output.js'
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
import { WorkspaceListOutput } from './handlers.js'
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
const baseBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
}
describe('runGetWorkspace', () => {
@ -36,8 +35,8 @@ describe('runGetWorkspace', () => {
return createClient({ host: mock.url, bearer: 'dfoa_test' })
}
async function render(format = '', activeCtx = baseActive): Promise<string> {
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
async function render(format = '', bundle = baseBundle): Promise<string> {
const result = await runGetWorkspace({ format }, { bundle, http: http() })
if (result.kind === 'empty')
return result.message
return stringifyOutput(table({
@ -76,8 +75,8 @@ describe('runGetWorkspace', () => {
}
})
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' } } }
it('falls back to bundle workspace.id when server current=false', async () => {
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
const out = await render('', overridden)
for (const line of out.split('\n')) {
if (line.includes('ws-2'))

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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.active.ctx.workspace?.id ?? ''
const currentId = deps.bundle.workspace?.id ?? ''
return {
kind: 'output',
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(

View File

@ -1,5 +1,4 @@
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { resumeApp } from './run.js'
@ -26,7 +25,7 @@ export default class ResumeApp extends DifyCommand {
'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }),
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
'http-retry': httpRetryFlag,
}
@ -49,7 +48,7 @@ export default class ResumeApp extends DifyCommand {
stream: flags.stream,
think: flags.think,
},
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ bundle: ctx.bundle, 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })

View File

@ -1,5 +1,4 @@
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { agentGuide } from './guide.js'
@ -33,7 +32,7 @@ export default class RunApp extends DifyCommand {
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'Output format (json|yaml|text)', default: '' }),
}
async run(argv: string[]): Promise<void> {
@ -54,7 +53,7 @@ export default class RunApp extends DifyCommand {
stream: flags.stream,
think: flags.think,
},
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ bundle: ctx.bundle, 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 { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -7,52 +7,44 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { loadAppInfoCache } from '../../../cache/app-info.js'
import { createClient } from '../../../http/client.js'
import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
import { YamlStore } from '../../../store/store.js'
import { bufferStreams } from '../../../sys/io/streams'
import { resumeApp } from '../../resume/app/run.js'
import { runApp } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
host: 'http://localhost',
email: 't@d.ai',
ctx: {
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
current_host: 'http://localhost',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
describe('runApp', () => {
let mock: DifyMock
let dir: string
let prevCacheDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-'))
prevCacheDir = process.env[ENV_CACHE_DIR]
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
it('chat: prints answer + conversation hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: hi\n')
expect(io.errBuf()).toContain('--conversation conv-1')
@ -60,29 +52,29 @@ describe('runApp', () => {
it('workflow: rejects positional message with usage error', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await expect(runApp(
{ appId: 'app-2', message: 'hi' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
})
it('workflow: prints single-string output as plain text', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' } },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
it('json: passes through full envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
expect(parsed.mode).toBe('chat')
@ -93,7 +85,7 @@ describe('runApp', () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', format: 'bogus' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/not supported/)
})
@ -102,7 +94,7 @@ describe('runApp', () => {
await expect(runApp(
{ appId: 'nope', message: 'hi' },
{
active: active(),
bundle: bundle(),
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
io,
@ -112,10 +104,10 @@ describe('runApp', () => {
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('echo: ')
expect(io.outBuf()).toContain('hi')
@ -124,10 +116,10 @@ describe('runApp', () => {
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
expect(parsed.mode).toBe('chat')
@ -137,10 +129,10 @@ describe('runApp', () => {
it('agent-chat without --stream: collects and prints answer', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('do research')
expect(io.errBuf()).toContain('--conversation conv-1')
@ -148,10 +140,10 @@ describe('runApp', () => {
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('go')
expect(io.errBuf()).toContain('thought:')
@ -159,10 +151,10 @@ describe('runApp', () => {
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
expect(parsed.mode).toBe('workflow')
@ -172,22 +164,22 @@ describe('runApp', () => {
it('stream-error scenario: error event surfaces typed BaseError', async () => {
mock.setScenario('stream-error')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
)).rejects.toMatchObject({ code: 'server_5xx' })
})
it('--inputs-file: reads inputs from file', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const inputsFile = join(dir, 'inputs.json')
const { writeFile } = await import('node:fs/promises')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
await runApp(
{ appId: 'app-2', inputsFile },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
@ -199,16 +191,16 @@ describe('runApp', () => {
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
await expect(runApp(
{ appId: 'app-2', inputsFile },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/must be a JSON object/)
})
it('--inputs: accepts JSON object string', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
@ -220,19 +212,19 @@ describe('runApp', () => {
await writeFile(inputsFile, '{}')
await expect(runApp(
{ appId: 'app-2', inputsJson: '{}', inputsFile },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/mutually exclusive/)
})
it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {} },
{
active: active(),
bundle: bundle(),
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
host: mock.url,
io,
@ -256,12 +248,12 @@ describe('runApp', () => {
it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => {
mock.setScenario('hitl-pause')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
let exitCode = -1
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
{
active: active(),
bundle: bundle(),
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
host: mock.url,
io,
@ -282,10 +274,10 @@ describe('runApp', () => {
it('resume: withHistory: false completes successfully', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
@ -293,10 +285,10 @@ describe('runApp', () => {
it('resume: submits form and streams workflow to completion', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
@ -304,10 +296,10 @@ describe('runApp', () => {
it('resume --stream: live-prints workflow node progress to stderr', async () => {
mock.setScenario('hitl-resume')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), 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')
@ -315,10 +307,10 @@ describe('runApp', () => {
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
expect(mock.uploadCallCount).toBe(0)
@ -334,12 +326,12 @@ describe('runApp', () => {
it('workflow: --file @path uploads file and passes local_file input variable', async () => {
const { writeFile } = await import('node:fs/promises')
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
const filePath = join(dir, 'test.pdf')
await writeFile(filePath, 'fake pdf content')
await runApp(
{ appId: 'app-2', files: [`doc=@${filePath}`] },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
expect(mock.uploadCallCount).toBe(1)
@ -353,10 +345,10 @@ describe('runApp', () => {
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
const io = bufferStreams()
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
await runApp(
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const m = await meta.get(opts.appId, wsId, [FieldInfo])

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runSetMember } from './run.js'
@ -27,7 +27,7 @@ export default class SetMember extends DifyCommand {
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
}
async run(argv: string[]) {
@ -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 },
{ active: ctx.active, http: ctx.http, io: ctx.io },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams'
import { runSetMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
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' }],
},
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' }],
}
}
@ -28,7 +27,7 @@ describe('runSetMember', () => {
const result = await runSetMember(
{ memberId: 'acct-2', role: 'admin' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -47,7 +46,7 @@ describe('runSetMember', () => {
runSetMember(
{ memberId: 'acct-2', role: 'owner' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -63,7 +62,7 @@ describe('runSetMember', () => {
runSetMember(
{ memberId: '', role: 'admin' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -77,7 +76,7 @@ describe('runSetMember', () => {
await runSetMember(
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } 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 active: ActiveContext
readonly bundle: HostsBundle
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'),
active: deps.active,
bundle: deps.bundle,
})
await runWithSpinner(

View File

@ -26,8 +26,6 @@ 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'
@ -106,8 +104,6 @@ export const commandTree: CommandTree = {
},
use: {
subcommands: {
account: { command: UseAccount, subcommands: {} },
host: { command: UseHost, subcommands: {} },
workspace: { command: UseWorkspace, subcommands: {} },
},
},

View File

@ -1,22 +0,0 @@
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

@ -1,63 +0,0 @@
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

@ -1,76 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,50 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from '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

@ -1,54 +0,0 @@
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,8 +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 }, {
reg: ctx.reg,
active: ctx.active,
configDir: ctx.configDir,
bundle: ctx.bundle,
http: ctx.http,
io: ctx.io,
})

View File

@ -3,36 +3,27 @@ import type {
WorkspaceListResponse,
} from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
function makeRegistry(): Registry {
const reg = Registry.empty('file')
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
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: {
@ -60,32 +51,24 @@ function fakeClient(opts: {
describe('runUseWorkspace', () => {
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(configDir, { recursive: true, force: true })
})
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -94,65 +77,40 @@ describe('runUseWorkspace', () => {
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
expect(client.list).toHaveBeenCalledOnce()
const activeCtx = next.resolveActive()
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
expect(activeCtx?.ctx.available_workspaces).toEqual([
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
expect(next.available_workspaces).toEqual([
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = Registry.load()
const reloadedActive = reloaded?.resolveActive()
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
})
it('hosts.yml contains no bearer after switch', async () => {
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 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.
// bundle has ws-2 named "Stale Name"; server returns "Switched".
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ configDir, bundle: b, 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')
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.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 reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const before = Registry.load()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -162,8 +120,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -172,18 +130,16 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = Registry.load()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
const afterActive = after?.resolveActive()
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
expect(after?.workspace?.id).toBe('ws-1')
})
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const before = Registry.load()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -193,8 +149,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -202,15 +158,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = Registry.load()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
})
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -232,8 +187,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
reg,
active,
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,

View File

@ -1,7 +1,8 @@
import type { KyInstance } from 'ky'
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
import type { HostsBundle, 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'
@ -12,8 +13,8 @@ export type UseWorkspaceOptions = {
}
export type UseWorkspaceDeps = {
readonly reg: Registry
readonly active: ActiveContext
readonly configDir: string
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
@ -31,12 +32,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 `saveRegistry`.
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
*/
export async function runUseWorkspace(
opts: UseWorkspaceOptions,
deps: UseWorkspaceDeps,
): Promise<Registry> {
): Promise<HostsBundle> {
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
const client = factory(deps.http)
@ -60,13 +61,16 @@ export async function runUseWorkspace(
})
}
const nextCtx = {
...deps.active.ctx,
const next: HostsBundle = {
...deps.bundle,
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,
})),
}
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
deps.reg.save()
await saveHosts(deps.configDir, next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
return deps.reg
return next
}

View File

@ -1,5 +1,5 @@
import { Flags } from '../../framework/flags.js'
import { formatted, OutputFormat, raw, stringifyOutput } from '../../framework/output.js'
import { formatted, raw, stringifyOutput } from '../../framework/output.js'
import { colorEnabled } from '../../sys/io/color.js'
import { realStreams } from '../../sys/io/streams'
import { versionInfo } from '../../version/info.js'
@ -20,7 +20,11 @@ export default class Version extends DifyCommand {
]
static override flags = {
'output': Flags.outputFormat({ options: [OutputFormat.TEXT, OutputFormat.JSON, OutputFormat.YAML], default: '' }),
'output': Flags.string({
char: 'o',
description: 'output format (text|json|yaml)',
default: '',
}),
'client': Flags.boolean({ description: 'skip server probe' }),
'short': Flags.boolean({ description: 'print only the client semver' }),
'check-compat': Flags.boolean({

View File

@ -1,52 +1,48 @@
import type { YamlStore } from '../store/store'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isBaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
import { ENV_CONFIG_DIR } from '../store/dir'
import { getConfigurationStore } from '../store/manager'
import { YamlStore } from '../store/store'
import { loadConfig } from './config-loader'
import { FILE_NAME } from './schema'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('loadConfig', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await rm(dir, { recursive: true, force: true })
await mkdir(dir, { recursive: true }).catch(() => {})
})
it('returns found:false when config is missing', () => {
const r = loadConfig(getConfigurationStore())
it('returns found:false when config.yml is missing', () => {
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(false)
})
it('parses a minimal valid config', () => {
getConfigurationStore().setTyped({ schema_version: 1 })
const r = loadConfig(getConfigurationStore())
it('parses a minimal valid config.yml', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('parses defaults + state', () => {
getConfigurationStore().setTyped({
schema_version: 1,
defaults: { format: 'json', limit: 100 },
state: { current_app: 'app-1' },
})
const r = loadConfig(getConfigurationStore())
it('parses defaults + state', async () => {
await writeFile(
join(dir, FILE_NAME),
'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n',
'utf8',
)
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('json')
@ -55,29 +51,11 @@ describe('loadConfig', () => {
}
})
it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => {
// Simulate a corrupt on-disk file via a fake store; loadConfig must wrap
// the underlying error as ConfigSchemaUnsupported.
const throwingStore = {
getTyped: () => { throw new Error('YAML parse failure') },
} as unknown as YamlStore
it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => {
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
let caught: unknown
try {
loadConfig(throwingStore)
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught)) {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
expect(caught.hint).toMatch(/not valid YAML/)
}
})
it('throws BaseError(config_schema_unsupported) when zod validation fails', () => {
getConfigurationStore().setTyped({ defaults: { limit: 9999 } })
let caught: unknown
try {
loadConfig(getConfigurationStore())
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
@ -85,11 +63,23 @@ describe('loadConfig', () => {
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
getConfigurationStore().setTyped({ schema_version: 2 })
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
let caught: unknown
try {
loadConfig(getConfigurationStore())
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
})
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => {
await writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8')
let caught: unknown
try {
loadConfig(makeStore(dir))
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)

View File

@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
import { CONFIG_FILE_NAME } from '../store/manager.js'
import {
ALLOWED_FORMATS,
ConfigFileSchema,
CURRENT_SCHEMA_VERSION,
emptyConfig,
FILE_NAME,
} from './schema.js'
describe('config schema', () => {
@ -12,8 +12,8 @@ describe('config schema', () => {
expect(CURRENT_SCHEMA_VERSION).toBe(1)
})
it('CONFIG_FILE_NAME is config.yml', () => {
expect(CONFIG_FILE_NAME).toBe('config.yml')
it('FILE_NAME is config.yml', () => {
expect(FILE_NAME).toBe('config.yml')
})
it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => {

View File

@ -1,6 +1,7 @@
import { z } from 'zod'
export const CURRENT_SCHEMA_VERSION = 1
export const FILE_NAME = 'config.yml'
export const ALLOWED_FORMATS = ['json', 'yaml', 'table', 'wide', 'name', 'text'] as const
export type AllowedFormat = (typeof ALLOWED_FORMATS)[number]

View File

@ -8,8 +8,8 @@ import {
} from './codes.js'
describe('error codes', () => {
it('has correct number codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(Object.keys(CODE_TO_EXIT_MAP).length)
it('has 17 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(17)
})
it('has the expected ExitCode buckets', () => {
@ -46,7 +46,6 @@ describe('error codes', () => {
[ErrorCode.NetworkDns, ExitCode.Generic],
[ErrorCode.Server5xx, ExitCode.Generic],
[ErrorCode.Server4xxOther, ExitCode.Generic],
[ErrorCode.ClientError, ExitCode.Generic],
[ErrorCode.Unknown, ExitCode.Generic],
])('exitFor(%s) -> %d', (code, want) => {
expect(exitFor(code)).toBe(want)

View File

@ -15,9 +15,7 @@ export const ErrorCode = {
NetworkDns: 'network_dns',
Server5xx: 'server_5xx',
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
IllegalArgumentError: 'illegal_argument',
} as const
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
@ -49,9 +47,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
network_dns: ExitCode.Generic,
server_5xx: ExitCode.Generic,
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
unknown: ExitCode.Generic,
illegal_argument: ExitCode.Usage,
}
export function exitFor(code: string): ExitCodeValue {

View File

@ -1,34 +0,0 @@
import type { FlagDefinition } from './types.js'
import { describe, expect, it } from 'vitest'
import { OutputFormatNotSupportedError, UnsupportedArgValueError } from './errors.js'
describe('OutputFormatNotSupportedError', () => {
it('states the offending format in the message', () => {
const err = new OutputFormatNotSupportedError('csv')
expect(err.message).toBe('format csv is not supported by this command')
})
})
describe('UnsupportedArgValueError', () => {
it('includes both long and short option labels when a char exists', () => {
const def: FlagDefinition = { type: 'string', description: 'output', char: 'o', options: ['json', 'yaml'] }
const err = new UnsupportedArgValueError('output', def, 'csv')
expect(err.message).toBe('illegal value csv for flag --output / -o')
})
it('omits the short option label when the flag has no char', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] }
const err = new UnsupportedArgValueError('mode', def, 'chatbot')
expect(err.message).toBe('illegal value chatbot for flag --mode')
})
it('lists supported values in the hint', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode', options: ['chat', 'workflow'] }
expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('supported value: chat, workflow')
})
it('leaves the hint empty when the flag declares no options', () => {
const def: FlagDefinition = { type: 'string', description: 'app mode' }
expect(new UnsupportedArgValueError('mode', def, 'chatbot').hint).toBe('')
})
})

View File

@ -1,23 +0,0 @@
import type { FlagDefinition } from './types'
import { BaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
export class OutputFormatNotSupportedError extends BaseError {
constructor(format: string) {
super({
code: ErrorCode.IllegalArgumentError,
message: `format ${format} is not supported by this command`,
})
}
}
export class UnsupportedArgValueError extends BaseError {
constructor(flagName: string, flagDef: FlagDefinition, givenValue: string) {
const flagLabel = flagDef.char ? `--${flagName} / -${flagDef.char}` : `--${flagName}`
super({
code: ErrorCode.IllegalArgumentError,
message: `illegal value ${givenValue} for flag ${flagLabel}`,
hint: flagDef.options ? `supported value: ${flagDef.options.join(', ')}` : '',
})
}
}

View File

@ -1,5 +1,4 @@
import { describe, expect, it } from 'vitest'
import { UnsupportedArgValueError } from './errors.js'
import { Args, Flags, parseArgv } from './flags.js'
const meta = {
@ -191,13 +190,13 @@ describe('parseArgv', () => {
it('rejects an invalid option value (space form)', () => {
expect(() => parseArgv(['--mode', 'chatbot'], metaWithOptions)).toThrow(
UnsupportedArgValueError,
'--mode must be one of: chat, workflow, completion',
)
})
it('rejects an invalid option value (= form)', () => {
expect(() => parseArgv(['--mode=chatbot'], metaWithOptions)).toThrow(
UnsupportedArgValueError,
'--mode must be one of: chat, workflow, completion',
)
})
})

View File

@ -1,12 +1,6 @@
import type { ArgDefinition, CommandMeta, FlagDefinition, ParsedArgs, ParsedFlags } from './types.js'
import { UnsupportedArgValueError } from './errors.js'
function stringFlag<const Opts extends {
description: string
char?: string
default?: string
options?: readonly string[]
}>(
function stringFlag<const Opts extends { description: string, char?: string, default?: string, multiple?: boolean, helpGroup?: string, options?: readonly string[] }>(
opts: Opts,
): FlagDefinition<string> {
return {
@ -16,19 +10,7 @@ function stringFlag<const Opts extends {
}
}
function outputFormatFlag<const Opts extends { options: readonly string[], default?: string }>(
opts: Opts,
): FlagDefinition<string> {
return {
type: 'string',
description: `output format (${opts.options.join('|')})`,
char: 'o',
multiple: false,
...opts,
}
}
function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean }>(
function stringRepeatedFlag<const Opts extends { description: string, char?: string, default?: string[], multiple?: boolean, helpGroup?: string }>(
opts: Opts,
): FlagDefinition<string[]> {
return {
@ -38,11 +20,11 @@ function stringRepeatedFlag<const Opts extends { description: string, char?: str
}
}
function booleanFlag(opts: { description: string, char?: string, default?: boolean }): FlagDefinition<boolean> {
function booleanFlag(opts: { description: string, char?: string, default?: boolean, helpGroup?: string }): FlagDefinition<boolean> {
return { type: 'boolean', ...opts }
}
function integerFlag<const Opts extends { description: string, char?: string, default?: number }>(
function integerFlag<const Opts extends { description: string, char?: string, default?: number, helpGroup?: string }>(
opts: Opts,
): FlagDefinition<Opts extends { default: number } ? number : number | undefined> {
return { type: 'integer', ...opts } as FlagDefinition<Opts extends { default: number } ? number : number | undefined>
@ -53,7 +35,6 @@ export const Flags = {
stringArray: stringRepeatedFlag,
boolean: booleanFlag,
integer: integerFlag,
outputFormat: outputFormatFlag,
}
function stringArg<const Opts extends { description: string, required?: boolean }>(
@ -110,32 +91,7 @@ function resolveByChar(char: string, meta: CommandMeta): [name: string, def: Fla
function validateFlagOptions(name: string, raw: string, def: FlagDefinition): void {
if (def.options !== undefined && !def.options.includes(raw))
throw new UnsupportedArgValueError(name, def, raw)
}
type ResolvedFlag = { name: string, def: FlagDefinition, label: string, inlineRaw: string | undefined }
function resolveToken(token: string, meta: CommandMeta): ResolvedFlag | null {
if (token.startsWith('--')) {
const eqIdx = token.indexOf('=')
const name = eqIdx !== -1 ? token.slice(2, eqIdx) : token.slice(2)
const inlineRaw = eqIdx !== -1 ? token.slice(eqIdx + 1) : undefined
const def = meta.flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
return { name, def, label: `--${name}`, inlineRaw }
}
if (token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const [name, def] = resolved
return { name, def, label: `-${char}`, inlineRaw: undefined }
}
return null
throw new Error(`--${name} must be one of: ${def.options.join(', ')}`)
}
export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: ParsedArgs, flags: ParsedFlags } {
@ -154,38 +110,63 @@ export function parseArgv(argv: readonly string[], meta: CommandMeta): { args: P
continue
}
if (pastDoubleDash || !token.startsWith('-')) {
positional.push(token)
continue
if (!pastDoubleDash && token.startsWith('--')) {
const eqIdx = token.indexOf('=')
let name: string
let rawValue: string | undefined
if (eqIdx !== -1) {
name = token.slice(2, eqIdx)
rawValue = token.slice(eqIdx + 1)
}
else {
name = token.slice(2)
rawValue = undefined
}
const def = meta.flags[name]
if (!def)
throw new Error(`unknown flag: --${name}`)
if (def.type === 'boolean') {
flags[name] = rawValue === undefined ? true : coerceFlagValue(rawValue, def)
}
else if (rawValue !== undefined) {
validateFlagOptions(name, rawValue, def)
accumulateFlagValue(flags, name, coerceFlagValue(rawValue, def), def)
}
else {
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag --${name} expects a value`)
validateFlagOptions(name, next, def)
accumulateFlagValue(flags, name, coerceFlagValue(next, def), def)
}
}
else if (!pastDoubleDash && token.startsWith('-') && token.length === 2 && token[1] !== undefined) {
const char = token[1]
const resolved = resolveByChar(char, meta)
if (!resolved)
throw new Error(`unknown flag: -${char}`)
const resolved = resolveToken(token, meta)
if (!resolved) {
positional.push(token)
continue
}
const [flagName, def] = resolved
if (def.type === 'boolean') {
flags[flagName] = true
}
else {
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag -${char} expects a value`)
const { name, def, label, inlineRaw } = resolved
if (def.type === 'boolean') {
flags[name] = inlineRaw === undefined ? true : coerceFlagValue(inlineRaw, def)
continue
}
let raw: string
if (inlineRaw !== undefined) {
raw = inlineRaw
accumulateFlagValue(flags, flagName, coerceFlagValue(next, def), def)
}
}
else {
i++
const next = i < argv.length ? argv[i] : undefined
if (next === undefined || next.startsWith('-'))
throw new Error(`flag ${label} expects a value`)
raw = next
positional.push(token)
}
validateFlagOptions(name, raw, def)
accumulateFlagValue(flags, name, coerceFlagValue(raw, def), def)
}
const args: ParsedArgs = {}

View File

@ -1,6 +1,5 @@
import type { FormattedPrintable, NamePrintable, TablePrintable } from './output.js'
import { describe, expect, it } from 'vitest'
import { OutputFormatNotSupportedError } from './errors.js'
import {
formatted,
raw,
@ -100,12 +99,13 @@ describe('stringifyOutput — formatted', () => {
json: () => ({}),
}
const out = formatted({ format: 'name', data: noName })
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
})
it('unknown format: throws with allowed list', () => {
const out = formatted({ format: 'csv', data: makeFormatted({}) })
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
expect(() => stringifyOutput(out)).toThrow(/not supported/)
expect(() => stringifyOutput(out)).toThrow(/json, name, text, yaml/)
})
})
@ -175,12 +175,13 @@ describe('stringifyOutput — table', () => {
json: () => [],
}
const out = table({ format: 'name', data: noName })
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
})
it('unknown format: throws with allowed list', () => {
const out = table({ format: 'csv', data: makeTable({}) })
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
expect(() => stringifyOutput(out)).toThrow(/not supported/)
expect(() => stringifyOutput(out)).toThrow(/json, name, wide, yaml/)
})
it('table renders column padding correctly', () => {

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