Compare commits

..

3 Commits

183 changed files with 2654 additions and 5414 deletions

0
.codex Normal file
View File

View File

@ -1,8 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "dify"
[setup]
script = '''
pnpm install --frozen-lockfile --prefer-offline
'''

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

@ -467,8 +467,7 @@ class AppListApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
def get(self, session: Session):
def get(self):
"""Get app list"""
current_user, current_tenant_id = current_account_with_tenant()
@ -505,7 +504,7 @@ class AppListApi(Resource):
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
db.session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),

View File

@ -2,7 +2,6 @@ from collections.abc import Sequence
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console import console_ns
@ -12,7 +11,6 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import with_session
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from core.app.app_config.entities import ModelConfig
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
@ -21,6 +19,7 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required
@ -159,8 +158,7 @@ class InstructionGenerateApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
@with_session(write=False)
def post(self, session: Session, current_tenant_id: str):
def post(self, current_tenant_id: str):
args = InstructionGeneratePayload.model_validate(console_ns.payload)
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
code_provider: type[CodeNodeProvider] | None = next(
@ -170,10 +168,10 @@ class InstructionGenerateApi(Resource):
try:
# Generate from nothing for a workflow node
if (args.current in (code_template, "")) and args.node_id != "":
app = session.get(App, args.flow_id)
app = db.session.get(App, args.flow_id)
if not app:
return {"error": f"app {args.flow_id} not found"}, 400
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
workflow = WorkflowService().get_draft_workflow(app_model=app)
if not workflow:
return {"error": f"workflow {args.flow_id} not found"}, 400
nodes: Sequence = workflow.graph_dict["nodes"]

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

@ -140,21 +140,14 @@ class WorkflowService:
)
return db.session.execute(stmt).scalar_one()
def get_draft_workflow(
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
) -> Workflow | None:
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
"""
Get draft workflow
When ``session`` is provided, reuse it so callers that already hold a
Session avoid checking out an extra request-scoped ``db.session``
connection. Falls back to ``db.session`` for backward compatibility.
"""
if workflow_id:
return self.get_published_workflow_by_id(app_model, workflow_id, session=session)
return self.get_published_workflow_by_id(app_model, workflow_id)
# fetch draft workflow by app_model
bind = session if session is not None else db.session
workflow = bind.scalar(
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == app_model.tenant_id,

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

@ -7,7 +7,6 @@ from importlib import util
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from flask.views import MethodView
@ -19,15 +18,6 @@ if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _unwrap(func):
bound_self = getattr(func, "__self__", None)
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
if bound_self is not None:
return func.__get__(bound_self, bound_self.__class__)
return func
@pytest.fixture(scope="module")
def app_module():
module_name = "controllers.console.app.app"
@ -405,46 +395,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
assert len(serialized["data"]) == 2
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
assert serialized["data"][1]["icon_url"] is None
def test_app_list_uses_injected_session_for_draft_workflows(app, app_module, monkeypatch):
api = app_module.AppListApi()
method = _unwrap(api.get)
current_user = SimpleNamespace(id="user-1")
app_item = SimpleNamespace(
id="app-1",
name="Workflow App",
desc_or_prompt="Summary",
mode="workflow",
mode_compatible_with_agent="workflow",
)
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
workflow = SimpleNamespace(
id="workflow-1",
app_id="app-1",
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
)
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
scoped_session = SimpleNamespace(execute=MagicMock(side_effect=AssertionError("db.session should not be used")))
monkeypatch.setattr(app_module, "current_account_with_tenant", lambda: (current_user, "tenant-1"))
monkeypatch.setattr(
app_module,
"AppService",
lambda: SimpleNamespace(get_paginate_apps=lambda *_args, **_kwargs: app_pagination),
)
monkeypatch.setattr(
app_module,
"FeatureService",
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
response, status = method(session)
assert status == 200
assert response["data"][0]["has_draft_trigger"] is True
session.execute.assert_called_once()
scoped_session.execute.assert_not_called()

View File

@ -1,7 +1,6 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
@ -25,17 +24,10 @@ def _model_config_payload():
def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow):
class _Service:
app_model = None
session = None
def get_draft_workflow(self, app_model, session=None):
self.app_model = app_model
self.session = session
def get_draft_workflow(self, app_model):
return workflow
service = _Service()
monkeypatch.setattr(generator_module, "WorkflowService", lambda: service)
return service
monkeypatch.setattr(generator_module, "WorkflowService", lambda: _Service())
def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
@ -76,8 +68,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = MagicMock()
session.get.return_value = None
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
with app.test_request_context(
"/console/api/instruction-generate",
@ -89,11 +80,10 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "app app-1 not found"
session.get.assert_called_once_with(generator_module.App, "app-1")
def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
@ -101,7 +91,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
_install_workflow_service(monkeypatch, workflow=None)
with app.test_request_context(
@ -114,7 +104,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "workflow app-1 not found"
@ -125,7 +115,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
workflow = SimpleNamespace(graph_dict={"nodes": []})
_install_workflow_service(monkeypatch, workflow=workflow)
@ -140,7 +130,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "node node-1 not found"
@ -151,7 +141,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
method = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1")
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
workflow = SimpleNamespace(
graph_dict={
@ -160,7 +150,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
]
}
)
workflow_service = _install_workflow_service(monkeypatch, workflow=workflow)
_install_workflow_service(monkeypatch, workflow=workflow)
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", lambda **_kwargs: {"code": "x"})
with app.test_request_context(
@ -173,17 +163,14 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
"model_config": _model_config_payload(),
},
):
response = method(session, "t1")
response = method("t1")
assert response == {"code": "x"}
assert workflow_service.app_model is app_model
assert workflow_service.session is session
def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = SimpleNamespace()
monkeypatch.setattr(
generator_module.LLMGenerator,
@ -202,7 +189,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response = method(session, "t1")
response = method("t1")
assert response == {"instruction": "ok"}
@ -210,7 +197,6 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
session = SimpleNamespace()
with app.test_request_context(
"/console/api/instruction-generate",
@ -223,7 +209,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
"model_config": _model_config_payload(),
},
):
response, status = method(session, "t1")
response, status = method("t1")
assert status == 400
assert response["error"] == "incompatible parameters"

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

@ -346,19 +346,6 @@ class TestWorkflowService:
assert result == mock_workflow
def test_get_draft_workflow_uses_provided_session(self, workflow_service, mock_db_session):
"""Test get_draft_workflow can reuse an injected SQLAlchemy session."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
session = MagicMock()
session.scalar.return_value = mock_workflow
result = workflow_service.get_draft_workflow(app, session=session)
assert result == mock_workflow
session.scalar.assert_called_once()
mock_db_session.session.scalar.assert_not_called()
def test_get_draft_workflow_returns_none(self, workflow_service, mock_db_session):
"""Test get_draft_workflow returns None when no draft exists."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
@ -383,21 +370,6 @@ class TestWorkflowService:
assert result == mock_workflow
def test_get_draft_workflow_with_workflow_id_reuses_provided_session(self, workflow_service):
"""Test get_draft_workflow passes an injected session to published workflow lookup."""
app = TestWorkflowAssociatedDataFactory.create_app_mock()
workflow_id = "workflow-123"
session = MagicMock()
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
with patch.object(
workflow_service, "get_published_workflow_by_id", return_value=mock_workflow
) as mock_get_published:
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id, session=session)
assert result == mock_workflow
mock_get_published.assert_called_once_with(app, workflow_id, session=session)
# ==================== Get Published Workflow Tests ====================
# These tests verify retrieval of published workflows (versioned snapshots)

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,9 +1,9 @@
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 { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
import { FILE_PERM } from '../store/dir.js'
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
describe('HostsBundleSchema', () => {
it('parses a minimal logged-out bundle', () => {
@ -46,86 +46,86 @@ describe('HostsBundleSchema', () => {
})
expect(parsed.available_workspaces).toHaveLength(2)
})
it('drops unknown top-level fields on parse', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
future_field: 42,
token_storage: 'file',
})
expect(parsed.current_host).toBe('cloud.dify.ai')
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
})
})
describe('loadHosts/saveHosts', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
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 undefined when nothing was saved', () => {
expect(loadHosts()).toBeUndefined()
it('returns undefined when file is missing', async () => {
expect(await loadHosts(dir)).toBeUndefined()
})
it('round-trips a fully-populated bundle', () => {
saveHosts({
it('round-trips bundle through YAML', async () => {
await saveHosts(dir, {
current_host: 'cloud.dify.ai',
scheme: 'https',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'My Space', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'keychain',
token_id: 'tok_xyz',
})
const loaded = loadHosts()
const loaded = await loadHosts(dir)
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect(loaded?.scheme).toBe('https')
expect(loaded?.account?.email).toBe('a@b.c')
expect(loaded?.workspace?.id).toBe('ws-1')
expect(loaded?.available_workspaces).toHaveLength(2)
expect(loaded?.token_storage).toBe('keychain')
expect(loaded?.token_id).toBe('tok_xyz')
})
it('round-trips a file-mode bundle with bearer token', () => {
saveHosts({
current_host: 'self.example.com',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
})
const loaded = loadHosts()
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
expect(loaded?.token_storage).toBe('file')
})
it('overwrites previous bundle on save', () => {
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
const loaded = loadHosts()
expect(loaded?.current_host).toBe('new.example.com')
expect(loaded?.token_storage).toBe('keychain')
})
it('rejects invalid input at save time', () => {
expect(() => saveHosts({
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: 'cloud',
} as never)).toThrow()
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,6 +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 { 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>
@ -44,23 +48,53 @@ export const HostsBundleSchema = z.object({
})
export type HostsBundle = z.infer<typeof HostsBundleSchema>
export function loadHosts(): HostsBundle | undefined {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return undefined
return HostsBundleSchema.parse(raw)
}
export function saveHosts(bundle: HostsBundle): void {
const validated = HostsBundleSchema.parse(bundle)
getHostStore().setTyped(validated)
}
export function clearLocal(bundle: HostsBundle, store: Store): void {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
const path = join(dir, HOSTS_FILE_NAME)
let raw: string
try {
store.unset(tokenKey(bundle.current_host, accountId))
raw = await readFile(path, 'utf8')
}
catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
return undefined
throw err
}
const parsed = yaml.load(raw)
return HostsBundleSchema.parse(parsed ?? {})
}
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<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)
}
catch (err) {
try {
await unlink(tmp)
}
catch { /* tmp may not exist */ }
throw err
}
const { chmod, stat } = await import('node:fs/promises')
try {
const info = await stat(target)
if ((info.mode & 0o777) !== FILE_PERM)
await chmod(target, FILE_PERM)
}
catch { /* best-effort */ }
getHostStore().rm()
}
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

@ -12,6 +12,7 @@ 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 { resolveConfigDir } from '../../store/dir.js'
import { realStreams } from '../../sys/io/streams'
import { hostWithScheme } from '../../util/host.js'
import { versionInfo } from '../../version/info.js'
@ -23,6 +24,7 @@ export type AuthedContext = {
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
readonly configDir: string
readonly cache?: AppInfoCache
}
@ -36,8 +38,9 @@ export async function buildAuthedContext(
cmd: Pick<Command, 'error'>,
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const configDir = resolveConfigDir()
const io = realStreams(opts.format ?? '')
const bundle = loadHosts()
const bundle = await loadHosts(configDir)
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
@ -58,7 +61,7 @@ export async function buildAuthedContext(
await runCompatNudge({ host, io })
return { bundle, http, host, io, cache }
return { bundle, http, host, io, configDir, cache }
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -2,7 +2,7 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Key, Store } from '../../../../store/store.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'
@ -10,23 +10,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../../auth/hosts.js'
import { createClient } from '../../../../http/client.js'
import { ENV_CONFIG_DIR, resolveConfigDir } 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))
}
}
@ -90,18 +93,11 @@ describe('runDevicesList', () => {
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 })
})
@ -110,11 +106,11 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
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, bundle: b, http, store, 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)
})
@ -125,7 +121,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -135,7 +131,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
@ -145,7 +141,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -156,7 +152,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -167,7 +163,7 @@ describe('runDevicesRevoke', () => {
const b = bundleFor(mock.url, 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
@ -175,20 +171,20 @@ describe('runDevicesRevoke', () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
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, bundle: b, http, store, target: 'tok-1', all: false })
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
})
it('no target + no --all: throws UsageMissingArg', async () => {
const io = bufferStreams()
const store = new MemStore()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,14 +1,15 @@
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Store } from '../../../../store/store.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 { clearLocal } from '../../../../auth/hosts.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'
import { getTokenStore } from '../../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
import { runWithSpinner } from '../../../../sys/io/spinner.js'
@ -71,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
}
export type DevicesRevokeOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -103,10 +104,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
for (const id of ids)
await sessions.revoke(id)
if (selfHit) {
const tokens = opts.store ?? getTokenStore().store
clearLocal(b, tokens)
}
if (selfHit)
await clearLocal(opts.configDir, b, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@ -179,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

@ -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,10 +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,
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 })
})
@ -60,6 +58,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -74,7 +73,7 @@ describe('runLogin', () => {
expect(bundle.account?.email).toBe('tester@dify.ai')
expect(bundle.workspace?.id).toBe('ws-1')
expect(bundle.available_workspaces).toHaveLength(2)
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
const stored = await store.get(bundle.current_host, 'acct-1')
expect(stored).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
@ -92,6 +91,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -106,8 +106,6 @@ describe('runLogin', () => {
expect(bundle.account).toBeUndefined()
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
expect(stored).toBe('dfoe_test')
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,
@ -152,6 +152,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,
@ -168,6 +169,7 @@ describe('runLogin', () => {
const io = bufferStreams()
const store = new MemStore()
await runLogin({
configDir,
io,
host: mock.url,
noBrowser: true,

View File

@ -1,6 +1,6 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { HostsBundle, 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'
@ -8,20 +8,21 @@ import * as os from 'node:os'
import * as readline from 'node:readline'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { saveHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { createClient } from '../../../http/client.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
import { awaitAuthorization, realClock } from './device-flow.js'
export type LoginOptions = {
readonly configDir: string
readonly io: IOStreams
readonly host?: string
readonly noBrowser?: boolean
readonly insecure?: boolean
readonly deviceLabel?: string
readonly store?: { readonly store: Store, readonly mode: StorageMode }
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
@ -58,11 +59,11 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const storeBundle = opts.store ?? getTokenStore()
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(bundle)
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
await saveHosts(opts.configDir, bundle)
renderLoggedIn(opts.io.out, cs, host, success)
return bundle
@ -99,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)
@ -139,11 +140,11 @@ function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): Hos
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account) {
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 || s.account.id === '')) {
&& (s.account === undefined || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',

View File

@ -1,6 +1,8 @@
import type { KyInstance } from 'ky'
import { loadHosts } from '../../../auth/hosts.js'
import { selectStore } from '../../../auth/store.js'
import { createClient } from '../../../http/client.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'
@ -16,7 +18,9 @@ export default class Logout extends DifyCommand {
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
const bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
const { store } = await selectStore({ configDir })
let http: KyInstance | undefined
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
@ -30,7 +34,7 @@ export default class Logout extends DifyCommand {
const io = realStreams()
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ io, bundle, http }),
() => runLogout({ configDir, io, bundle, http, store }),
)
}
}

View File

@ -1,6 +1,6 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Key, Store } from '../../../store/store.js'
import 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'
@ -8,23 +8,28 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogout } from './logout.js'
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
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))
}
}
@ -47,20 +52,13 @@ function fixtureBundle(host: string): HostsBundle {
describe('runLogout', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
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 })
})
@ -69,11 +67,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
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({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
@ -84,7 +82,7 @@ describe('runLogout', () => {
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
@ -93,7 +91,7 @@ describe('runLogout', () => {
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
@ -102,12 +100,12 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
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({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
@ -119,11 +117,11 @@ describe('runLogout', () => {
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
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({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
@ -133,11 +131,11 @@ describe('runLogout', () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
saveHosts(bundle)
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({ io, bundle, http, store })
await runLogout({ configDir, io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')

View File

@ -1,20 +1,21 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.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 { clearLocal } from '../../../auth/hosts.js'
import { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { getTokenStore } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type LogoutOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http?: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly store: TokenStore
}
export async function runLogout(opts: LogoutOptions): Promise<void> {
@ -39,8 +40,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
}
}
const tokens = opts.store ?? getTokenStore().store
clearLocal(bundle, tokens)
await clearLocal(opts.configDir, bundle, opts.store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
@ -52,3 +52,19 @@ 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 { 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 bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
}
}

View File

@ -1,5 +1,6 @@
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 bundle = loadHosts()
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runWhoami({ io: realStreams(), bundle, json: flags.json })
}
}

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[]) {

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

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

View File

@ -8,8 +8,8 @@ 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 bundle(): HostsBundle {
@ -29,24 +29,17 @@ function bundle(): HostsBundle {
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,
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
@ -89,7 +82,7 @@ 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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },

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[]) {

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[]) {

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,7 +15,7 @@ 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[]) {

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

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

View File

@ -7,8 +7,8 @@ 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'
@ -30,25 +30,18 @@ function bundle(): HostsBundle {
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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -59,7 +52,7 @@ 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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -68,7 +61,7 @@ describe('runApp', () => {
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' } },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -78,7 +71,7 @@ describe('runApp', () => {
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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -111,7 +104,7 @@ 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 },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -123,7 +116,7 @@ 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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -136,7 +129,7 @@ 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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -147,7 +140,7 @@ 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 },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -158,7 +151,7 @@ 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' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -171,7 +164,7 @@ 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 },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
@ -180,7 +173,7 @@ describe('runApp', () => {
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' }))
@ -204,7 +197,7 @@ describe('runApp', () => {
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"}' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -226,7 +219,7 @@ describe('runApp', () => {
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: {} },
@ -255,7 +248,7 @@ 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' },
@ -281,7 +274,7 @@ 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 },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -292,7 +285,7 @@ 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: {} },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -303,7 +296,7 @@ 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 },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -314,7 +307,7 @@ 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'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
@ -333,7 +326,7 @@ 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(
@ -352,7 +345,7 @@ 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'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },

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[]) {

View File

@ -22,6 +22,7 @@ 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 }, {
configDir: ctx.configDir,
bundle: ctx.bundle,
http: ctx.http,
io: ctx.io,

View File

@ -9,7 +9,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
@ -52,29 +51,23 @@ 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 b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -89,7 +82,7 @@ describe('runUseWorkspace', () => {
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = loadHosts()
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\)/)
@ -100,15 +93,15 @@ describe('runUseWorkspace', () => {
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = loadHosts()
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
@ -116,8 +109,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -127,6 +120,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -136,7 +130,7 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = loadHosts()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
})
@ -144,8 +138,8 @@ describe('runUseWorkspace', () => {
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -155,6 +149,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
@ -163,14 +158,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = loadHosts()
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 b = bundle()
saveHosts(b)
await saveHosts(configDir, b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -192,6 +187,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,

View File

@ -13,6 +13,7 @@ export type UseWorkspaceOptions = {
}
export type UseWorkspaceDeps = {
readonly configDir: string
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
@ -69,7 +70,7 @@ export async function runUseWorkspace(
role: w.role,
})),
}
saveHosts(next)
await saveHosts(deps.configDir, next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
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', () => {

View File

@ -1,5 +1,4 @@
import yaml from 'js-yaml'
import { OutputFormatNotSupportedError } from './errors'
export type RawOutput = {
readonly kind: 'raw'
@ -32,14 +31,6 @@ export type JsonPrintable = {
readonly json: () => unknown
}
export const OutputFormat = {
NAME: 'name',
JSON: 'json',
YAML: 'yaml',
TEXT: 'text',
WIDE: 'wide',
} as const
export type TableOutput<TRow extends TablePrintable> = {
readonly kind: 'table'
readonly format: string
@ -86,32 +77,32 @@ export function stringifyOutput(output: CommandOutput): string {
function stringifyFormattedOutput(output: FormattedOutput<FormattedPrintable>): string {
switch (output.format) {
case '':
case OutputFormat.TEXT:
case 'text':
return output.data.text()
case OutputFormat.JSON:
case 'json':
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case OutputFormat.YAML:
case 'yaml':
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case OutputFormat.NAME:
case 'name':
return `${toName(output.data)}\n`
default:
throw new OutputFormatNotSupportedError(output.format)
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, text, yaml`)
}
}
function stringifyTableOutput(output: TableOutput<TablePrintable>): string {
switch (output.format) {
case '':
case OutputFormat.WIDE:
case 'wide':
return renderTable(output)
case OutputFormat.JSON:
case 'json':
return `${JSON.stringify(output.data.json(), null, 2)}\n`
case OutputFormat.YAML:
case 'yaml':
return yaml.dump(output.data.json(), { indent: 2, lineWidth: -1 })
case OutputFormat.NAME:
case 'name':
return `${toName(output.data)}\n`
default:
throw new OutputFormatNotSupportedError(output.format)
throw new Error(`output format ${JSON.stringify(output.format)} not supported, allowed: json, name, wide, yaml`)
}
}
@ -195,7 +186,7 @@ function formatTable(rows: readonly (readonly string[])[]): string {
function toName(data: TablePrintable | FormattedPrintable): string {
if (!isNamePrintable(data))
throw new OutputFormatNotSupportedError('name')
throw new Error('name output requires data.name()')
return data.name()
}

View File

@ -9,6 +9,7 @@ export type FlagDefinition<T extends OptionalArgValueType = OptionalArgValueType
readonly char?: string
readonly default?: ArgValueType
readonly multiple?: boolean
readonly helpGroup?: string
readonly options?: readonly string[]
readonly _flagValue?: T
}

View File

@ -1,57 +1,45 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readdir, readFile, stat } 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 { loadConfig } from '../config/config-loader'
import { emptyConfig } from '../config/schema'
import { emptyConfig, FILE_NAME } from '../config/schema'
import { platform } from '../sys'
import { saveConfig } from './config-writer'
import { ENV_CONFIG_DIR } from './dir'
import { getConfigurationStore } from './manager'
import { YamlStore } from './store'
function makeStore(dir: string): YamlStore {
return new YamlStore(join(dir, FILE_NAME))
}
describe('saveConfig', () => {
let dir: string
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-w-'))
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('writes config.yml in the target dir', async () => {
saveConfig(makeStore(dir), { ...emptyConfig(), schema_version: 1 })
const stats = await stat(join(dir, FILE_NAME))
expect(stats.isFile()).toBe(true)
})
it('stamps schema_version=1 even if caller passed 0', () => {
saveConfig(getConfigurationStore(), { ...emptyConfig() })
const r = loadConfig(getConfigurationStore())
saveConfig(makeStore(dir), { ...emptyConfig() })
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('overrides a stale schema_version on save', () => {
saveConfig(getConfigurationStore(), {
...emptyConfig(),
schema_version: 999 as never,
})
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
})
it('round-trips defaults + state', () => {
saveConfig(getConfigurationStore(), {
it('round-trips defaults + state through YAML', () => {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'wide', limit: 75 },
state: { current_app: 'app-xyz' },
})
const r = loadConfig(getConfigurationStore())
const r = loadConfig(makeStore(dir))
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('wide')
@ -60,22 +48,39 @@ describe('saveConfig', () => {
}
})
it('overwrites the previous config on resave', () => {
saveConfig(getConfigurationStore(), {
it('writes file with mode 0o600 (POSIX)', async () => {
if (platform() === 'win32')
return
saveConfig(makeStore(dir), emptyConfig())
const s = await stat(join(dir, FILE_NAME))
expect(s.mode & 0o777).toBe(0o600)
})
it('does not leave a tmp file on success', async () => {
saveConfig(makeStore(dir), emptyConfig())
const entries = await readdir(dir)
expect(entries.filter(f => f.endsWith('.tmp'))).toHaveLength(0)
expect(entries.filter(f => f.includes('.tmp.'))).toHaveLength(0)
})
it('creates parent dir at 0o700 if absent', async () => {
if (platform() === 'win32')
return
const nested = join(dir, 'nested', 'sub')
saveConfig(makeStore(nested), emptyConfig())
const s = await stat(nested)
expect(s.isDirectory()).toBe(true)
expect(s.mode & 0o777).toBe(0o700)
})
it('emits parseable YAML (round-trip via fs.readFile + js-yaml)', async () => {
saveConfig(makeStore(dir), {
schema_version: 1,
defaults: { format: 'json' },
state: {},
})
saveConfig(getConfigurationStore(), {
schema_version: 1,
defaults: { format: 'table' },
state: { current_app: 'app-2' },
})
const r = loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found) {
expect(r.config.defaults.format).toBe('table')
expect(r.config.state.current_app).toBe('app-2')
}
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
expect(raw).toMatch(/^schema_version:/m)
expect(raw).toMatch(/format: json/)
})
})

View File

@ -1,64 +0,0 @@
import { BaseError } from '../errors/base'
import { ErrorCode } from '../errors/codes'
export class ConcurrentAccessError extends BaseError {
constructor(filePath: string) {
const msg = `Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`
super({
code: ErrorCode.ClientError,
message: msg,
hint: `remove ${filePath}.lock to reset lock.`,
})
}
}
type YamlMark = {
line: number
column: number
snippet?: string
}
type YamlParseError = {
reason?: string
mark?: YamlMark
message?: string
}
export class BadYamlFormatError extends BaseError {
constructor(path: string, raw: string, cause: YamlParseError) {
const reason = cause.reason ?? cause.message ?? 'invalid YAML'
const mark = cause.mark
const where = mark ? ` at line ${mark.line + 1}, column ${mark.column + 1}` : ''
const snippet = mark?.snippet ?? excerpt(raw, mark)
const header = `Failed to parse YAML file ${path}: ${reason}${where}.`
const body = snippet ? `\n\n${snippet}` : ''
super({
code: ErrorCode.ClientError,
message: `${header}${body}`,
hint: `Fix the YAML syntax in ${path} or remove the file to reset it.`,
})
}
}
function excerpt(raw: string, mark: YamlMark | undefined): string {
if (mark === undefined)
return ''
const lines = raw.split('\n')
const target = mark.line
if (target < 0 || target >= lines.length)
return ''
const start = Math.max(0, target - 2)
const end = Math.min(lines.length, target + 3)
const width = String(end).length
const out: string[] = []
for (let i = start; i < end; i++) {
const marker = i === target ? '>' : ' '
const num = String(i + 1).padStart(width, ' ')
out.push(`${marker} ${num} | ${lines[i]}`)
if (i === target)
out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`)
}
return out.join('\n')
}

View File

@ -1,109 +0,0 @@
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 FakeEntry {
private readonly key: string
constructor(service: string, username: string) {
this.key = `${service}::${username}`
}
setPassword(value: string): void {
setPassword(this.key, value)
passwords.set(this.key, value)
}
getPassword(): string | null {
getPassword(this.key)
return passwords.get(this.key) ?? null
}
deletePassword(): boolean {
deletePassword(this.key)
if (!passwords.has(this.key))
return false
passwords.delete(this.key)
return true
}
}
vi.mock('@napi-rs/keyring', () => ({
Entry: FakeEntry,
}))
const { KeyringBasedStore } = await import('./store.js')
const SERVICE = 'difyctl-test'
beforeEach(() => {
passwords.clear()
setPassword.mockClear()
getPassword.mockClear()
deletePassword.mockClear()
})
describe('KeyringBasedStore', () => {
it('returns default when entry missing', () => {
const s = new KeyringBasedStore(SERVICE)
expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback')
})
it('round-trips strings via JSON encoding', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'tok-abc')
expect(s.get({ key: 'k', default: '' })).toBe('tok-abc')
})
it('isolates entries by key', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'a', default: '' }, 'A')
s.set({ key: 'b', default: '' }, 'B')
expect(s.get({ key: 'a', default: '' })).toBe('A')
expect(s.get({ key: 'b', default: '' })).toBe('B')
})
it('unset removes the entry', () => {
const s = new KeyringBasedStore(SERVICE)
s.set({ key: 'k', default: '' }, 'v')
s.unset({ key: 'k', default: '' })
expect(s.get({ key: 'k', default: '' })).toBe('')
})
it('unset is a no-op when entry missing', () => {
const s = new KeyringBasedStore(SERVICE)
expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow()
})
it('swallows getPassword exceptions and returns default', () => {
const s = new KeyringBasedStore(SERVICE)
getPassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(s.get({ key: 'k', default: 'd' })).toBe('d')
})
it('swallows unset exceptions', () => {
const s = new KeyringBasedStore(SERVICE)
deletePassword.mockImplementationOnce(
() => {
throw new Error('NoEntry')
},
)
expect(() => s.unset({ key: 'k', default: '' })).not.toThrow()
})
it('lets set propagate exceptions (caller decides fallback)', () => {
const s = new KeyringBasedStore(SERVICE)
setPassword.mockImplementationOnce(
() => {
throw new Error('keyring locked')
},
)
expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/)
})
})

View File

@ -1,78 +0,0 @@
import type { Key, Store } from './store.js'
import { describe, expect, it, vi } from 'vitest'
import { getTokenStore } from './manager.js'
function memStore(label: string): Store & { _label: string } {
const map = new Map<string, unknown>()
return {
_label: label,
get<T>(key: Key<T>): T {
return (map.get(key.key) as T | undefined) ?? key.default
},
set<T>(key: Key<T>, value: T): void {
map.set(key.key, value)
},
unset<T>(key: Key<T>): void {
map.delete(key.key)
},
}
}
describe('getTokenStore', () => {
it('returns keychain store when probe succeeds', () => {
const k = memStore('keyring')
const f = memStore('file')
const result = getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('keychain')
expect(result.store).toBe(k)
})
it('falls back to file when keyring set throws', () => {
const k = memStore('keyring')
const f = memStore('file')
k.set = vi.fn(
() => {
throw new Error('locked')
},
)
const result = getTokenStore({
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', () => {
const k = memStore('keyring')
const f = memStore('file')
k.get = vi.fn(() => 'something-else')
const result = getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(result.mode).toBe('file')
expect(result.store).toBe(f)
})
it('falls back to file when keyring constructor throws', () => {
const f = memStore('file')
const result = getTokenStore({
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', () => {
const k = memStore('keyring')
const f = memStore('file')
getTokenStore({
factory: { keyring: () => k, file: () => f },
})
expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('')
})
})

View File

@ -1,77 +1,28 @@
import type { Key, StorageMode, Store } from './store'
import type { Store } from './store'
import { join } from 'node:path'
import { FILE_NAME } from '../config/schema'
import { resolveCacheDir, resolveConfigDir } from './dir'
import { KeyringBasedStore, YamlStore } from './store'
import { YamlStore } from './store'
export const CACHE_APP_INFO = 'app-info'
export const CACHE_NUDGE = 'nudge'
const HOSTS_FILE = 'hosts.yml'
const TOKENS_FILE = 'tokens.yml'
export const CONFIG_FILE_NAME = 'config.yml'
const KEYRING_SERVICE = 'difyctl'
function getStore(filePath: string): YamlStore {
return new YamlStore(filePath)
}
function resolveConfigurationPath(): string {
return join(resolveConfigDir(), FILE_NAME)
}
export function cachePath(cacheDir: string, name: string): string {
return join(cacheDir, `${name}.yml`)
}
export function getConfigurationStore(): YamlStore {
return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME))
return getStore(resolveConfigurationPath())
}
export function getCache(cacheName: string): Store {
return getStore(cachePath(resolveCacheDir(), cacheName))
}
export function getHostStore(): YamlStore {
return getStore(join(resolveConfigDir(), HOSTS_FILE))
}
const PROBE_KEY: Key<string> = { key: '__difyctl_probe__', default: '' }
const PROBE_VALUE = 'probe-v1'
export type GetTokenStoreOptions = {
readonly factory?: {
readonly keyring?: () => Store
readonly file?: () => Store
}
}
/**
* Single entry point for the credential store. Probes the OS keyring; if it
* round-trips a value, returns the keychain-backed store. Otherwise falls
* back to the YAML file at `<configDir>/tokens.yml`. Both implementations
* satisfy the `Store` interface, so callers interact uniformly.
*
* Business logic should always obtain the token store through this factory
* rather than constructing one directly.
*/
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } {
const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE)))
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE))
try {
const k = keyringFactory()
k.set(PROBE_KEY, PROBE_VALUE)
const got = k.get(PROBE_KEY)
k.unset(PROBE_KEY)
if (got !== PROBE_VALUE)
throw new Error('keyring round-trip mismatch')
return { store: k, mode: 'keychain' }
}
catch {
return { store: fileFactory(), mode: 'file' }
}
}
/**
* Maps an auth identity (host + accountId) to a `Store` key. All token store
* reads/writes in business logic go through this helper so the on-disk /
* keyring layout stays consistent.
*/
export function tokenKey(host: string, accountId: string): Key<string> {
return { key: `tokens.${host}.${accountId}`, default: '' }
}

View File

@ -1,10 +1,9 @@
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, 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 { BadYamlFormatError, ConcurrentAccessError } from './errors'
import { YamlStore } from './store'
import { ConcurrentAccessError, YamlStore } from './store'
describe('YamlStore.doGet', () => {
it('returns default when content is undefined', () => {
@ -14,51 +13,33 @@ describe('YamlStore.doGet', () => {
it('reads a flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
store.raw_content = 'name: alice\n'
expect(store.doGet({ key: 'name', default: '' })).toBe('alice')
})
it('reads a nested key via dot notation', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user:\n id: 42\n')
store.raw_content = 'user:\n id: 42\n'
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
})
it('returns default for a missing flat key', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\n')
store.raw_content = 'name: alice\n'
expect(store.doGet({ key: 'age', default: -1 })).toBe(-1)
})
it('returns default when an intermediate path segment is absent', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user:\n name: bob\n')
store.raw_content = 'user:\n name: bob\n'
expect(store.doGet({ key: 'user.address.city', default: 'unknown' })).toBe('unknown')
})
it('returns default when an intermediate path segment is a scalar', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user: scalar\n')
store.raw_content = 'user: scalar\n'
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(0)
})
it('throws BadYamlFormatError with file path, location, and snippet for malformed YAML', () => {
const path = '/irrelevant'
const store = new YamlStore(path)
store.setRawContent('name: alice\nuser:\n id: 42\n bad: indent\n')
let caught: unknown
try {
store.doGet({ key: 'name', default: '' })
}
catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(BadYamlFormatError)
const msg = (caught as BadYamlFormatError).message
expect(msg).toContain(path)
expect(msg).toMatch(/line \d+, column \d+/)
expect(msg).toContain('bad: indent')
})
})
describe('YamlStore.doSet', () => {
@ -76,7 +57,7 @@ describe('YamlStore.doSet', () => {
it('overwrites an existing key without disturbing siblings', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('name: alice\nage: 30\n')
store.raw_content = 'name: alice\nage: 30\n'
store.doSet({ key: 'name', default: '' }, 'bob')
expect(store.doGet({ key: 'name', default: '' })).toBe('bob')
expect(store.doGet({ key: 'age', default: 0 })).toBe(30)
@ -84,7 +65,7 @@ describe('YamlStore.doSet', () => {
it('replaces a scalar intermediate with an object when path deepens', () => {
const store = new YamlStore('/irrelevant')
store.setRawContent('user: scalar\n')
store.raw_content = 'user: scalar\n'
store.doSet({ key: 'user.id', default: 0 }, 99)
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
})
@ -151,12 +132,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
})
@ -165,12 +146,12 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
})
@ -179,17 +160,17 @@ describe('YamlStore persistence', () => {
await writeFile(path, '')
const s1 = new YamlStore(path)
s1.setRawContent('')
s1.raw_content = ''
s1.doSet({ key: 'x', default: '' }, 'first')
writeFileSync(path, s1.getRawContent() ?? '')
writeFileSync(path, s1.raw_content ?? '')
const s2 = new YamlStore(path)
s2.setRawContent(readFileSync(path, 'utf8'))
s2.raw_content = readFileSync(path, 'utf8')
s2.doSet({ key: 'y', default: '' }, 'second')
writeFileSync(path, s2.getRawContent() ?? '')
writeFileSync(path, s2.raw_content ?? '')
const s3 = new YamlStore(path)
s3.setRawContent(readFileSync(path, 'utf8'))
s3.raw_content = readFileSync(path, 'utf8')
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
})
@ -205,28 +186,8 @@ describe('YamlStore persistence', () => {
const raw = readFileSync(path, 'utf8')
const store2 = new YamlStore(path)
store2.setRawContent(raw)
store2.raw_content = raw
expect(store2.doGet({ key: 'token', default: '' })).toBe('abc-123')
expect(store2.doGet({ key: 'existing', default: '' })).toBe('value')
})
it('flush writes file when dirty (content changed from undefined)', () => {
const path = join(dir, 'config.yml')
const store = new YamlStore(path)
store.setRawContent('key: value\n')
store.flush()
expect(existsSync(path)).toBe(true)
expect(readFileSync(path, 'utf8')).toBe('key: value\n')
})
it('flush is a no-op when loaded content is set back unchanged', async () => {
const path = join(dir, 'config.yml')
await writeFile(path, 'key: value\n')
const store = new YamlStore(path)
store.load()
const mtime = statSync(path).mtimeMs
store.setRawContent('key: value\n')
store.flush()
expect(statSync(path).mtimeMs).toBe(mtime)
})
})

View File

@ -1,16 +1,14 @@
import type { Platform } from '../sys'
import fs from 'node:fs'
import { dirname } from 'node:path'
import { Entry } from '@napi-rs/keyring'
import yaml from 'js-yaml'
import lockfile from 'lockfile'
import { pid, resolvePlatform } from '../sys'
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
const FILE_PERM = 0o600
const DIR_PERM = 0o700
export type Key<T> = {
type Key<T> = {
default: T
key: string
}
@ -18,43 +16,38 @@ export type Key<T> = {
export type Store = {
get: <T>(key: Key<T>) => T
set: <T>(key: Key<T>, value: T) => void
unset: <T>(key: Key<T>) => void
}
export type StorageMode = 'keychain' | 'file'
export class ConcurrentAccessError extends Error {
constructor(filePath: string) {
super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`)
}
}
abstract class FileBasedStore implements Store {
filePath: string
private rawContent: string | undefined
file_path: string
raw_content: string | undefined
private readonly platform: Platform
private dirty: boolean = false
constructor(filePath: string) {
this.filePath = filePath
constructor(file_path: string) {
this.file_path = file_path
this.platform = resolvePlatform()
fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM })
}
unlock(): void {
lockfile.unlockSync(`${this.filePath}.lock`)
lockfile.unlockSync(`${this.file_path}.lock`)
}
/**
* atomically write raw_content (if any)
*/
flush(): void {
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
// we don't handle A-B-A scenario,
// which is not likely to happen in cli
if (!this.dirty) {
return
}
if (this.rawContent !== undefined) {
const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}`
if (this.raw_content !== undefined) {
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
try {
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.filePath)
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
this.platform.atomicReplace(tmp, this.file_path)
}
catch (err) {
try {
@ -64,20 +57,16 @@ abstract class FileBasedStore implements Store {
throw err
}
}
this.dirty = false
}
lock(): void {
try {
lockfile.lockSync(`${this.filePath}.lock`, {
stale: 30_000,
})
lockfile.lockSync(`${this.file_path}.lock`)
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') {
throw new ConcurrentAccessError(this.filePath)
throw new ConcurrentAccessError(this.file_path)
}
throw err
}
@ -85,8 +74,7 @@ abstract class FileBasedStore implements Store {
load(): void {
try {
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
this.dirty = false
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
@ -96,18 +84,10 @@ abstract class FileBasedStore implements Store {
}
}
public setRawContent(content: string): void {
this.dirty = (content !== this.getRawContent())
this.rawContent = content
}
public getRawContent(): string | undefined {
return this.rawContent
}
protected withLock<R>(body: () => R): R {
this.lock()
try {
this.load()
return body()
}
finally {
@ -116,44 +96,18 @@ abstract class FileBasedStore implements Store {
}
get<T>(key: Key<T>): T {
return this.withLock(() => {
this.load()
return this.doGet(key)
})
return this.withLock(() => this.doGet(key))
}
set<T>(key: Key<T>, value: T) {
this.withLock(() => {
this.load()
this.doSet(key, value)
this.flush()
})
}
unset<T>(key: Key<T>): void {
this.withLock(() => {
this.load()
this.doUnset(key)
this.flush()
})
}
/**
* Remove the underlying file of the store. No-op if file doesn't exist.
*/
rm(): void {
try {
fs.unlinkSync(this.filePath)
}
catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
throw err
}
}
abstract doGet<T>(key: Key<T>): T
abstract doSet<T>(key: Key<T>, value: T): void
abstract doUnset<T>(key: Key<T>): void
}
export class YamlStore extends FileBasedStore {
@ -162,7 +116,7 @@ export class YamlStore extends FileBasedStore {
}
doGet<T>(key: Key<T>): T {
const data = loadYaml(this.getRawContent(), this.filePath)
const data = loadYaml(this.raw_content)
const parts = key.key.split('.')
let current: unknown = data
for (const part of parts) {
@ -176,20 +130,19 @@ export class YamlStore extends FileBasedStore {
getTyped<T>(): T | null {
return this.withLock(() => {
this.load()
return loadYaml(this.getRawContent(), this.filePath) as T
return loadYaml(this.raw_content) as T
})
}
setTyped<T>(data: T): void {
this.withLock(() => {
this.load()
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
this.flush()
})
}
doSet<T>(key: Key<T>, value: T): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const data = loadYaml(this.raw_content) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
@ -201,74 +154,12 @@ export class YamlStore extends FileBasedStore {
current = current[part] as Record<string, unknown>
}
current[lastKey] = value
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
}
doUnset<T>(key: Key<T>): void {
const data = loadYaml(this.getRawContent(), this.filePath) || {}
const parts = key.key.split('.')
const lastKey = parts.pop()
if (lastKey === undefined)
return
let current: Record<string, unknown> = data
for (const part of parts) {
const next = current[part]
if (next === null || next === undefined || typeof next !== 'object')
return
current = next as Record<string, unknown>
}
if (!(lastKey in current))
return
delete current[lastKey]
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
}
}
function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null {
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
if (raw === undefined)
return null
try {
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}
catch (err) {
if (err instanceof yaml.YAMLException)
throw new BadYamlFormatError(file_path, raw, err)
throw err
}
}
/**
* OS-keyring-based storage primitive. Sits at the same layer as
* `FileBasedStore`: implements `Store` with each `Key<T>` corresponding to a
* single keyring entry under the configured service. Values are JSON-encoded.
*/
export class KeyringBasedStore implements Store {
private readonly service: string
constructor(service: string) {
this.service = service
}
get<T>(key: Key<T>): T {
try {
const v = new Entry(this.service, key.key).getPassword()
if (v === null || v === undefined || v === '')
return key.default
return JSON.parse(v) as T
}
catch {
return key.default
}
}
set<T>(key: Key<T>, value: T): void {
new Entry(this.service, key.key).setPassword(JSON.stringify(value))
}
unset<T>(key: Key<T>): void {
try {
new Entry(this.service, key.key).deletePassword()
}
catch { /* missing entry is fine */ }
}
return (yaml.load(raw) ?? {}) as Record<string, unknown>
}

View File

@ -5,8 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadNudgeStore } from '../cache/nudge-store.js'
import { ENV_CACHE_DIR } from '../store/dir.js'
import { CACHE_NUDGE, getCache } from '../store/manager.js'
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
import { YamlStore } from '../store/store.js'
import { maybeNudgeCompat } from './nudge.js'
const HOST = 'https://cloud.dify.ai'
@ -44,18 +44,11 @@ describe('maybeNudgeCompat', () => {
let dir: string
let store: NudgeStore
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
store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
})
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 })
})
@ -85,12 +78,12 @@ describe('maybeNudgeCompat', () => {
it('warns again after the silence window has elapsed', async () => {
const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000)
const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday })
const tStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday })
await tStore.markWarned(HOST)
const probe = vi.fn(async () => UNSUPPORTED)
const { emit, lines } = emitterSpy()
const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit }))
expect(probe).toHaveBeenCalledOnce()

View File

@ -160,8 +160,7 @@ describe('runVersionProbe', () => {
const url = new URL(mock.url)
const prevConfig = process.env[ENV_CONFIG_DIR]
try {
process.env[ENV_CONFIG_DIR] = configDir
saveHosts({
await saveHosts(configDir, {
current_host: url.host,
scheme: url.protocol.replace(':', ''),
token_storage: 'file',

View File

@ -5,6 +5,7 @@ import type { Channel } from './info.js'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
import { loadHosts } from '../auth/hosts.js'
import { createClient } from '../http/client.js'
import { resolveConfigDir } from '../store/dir.js'
import { arch, platform } from '../sys/index.js'
import { hostWithScheme } from '../util/host.js'
import { difyCompat, evaluateCompat } from './compat.js'
@ -47,7 +48,7 @@ export type RunVersionProbeOptions = {
readonly probe?: MetaProbe
}
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir())
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })

View File

@ -356,9 +356,6 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
subject_type: 'external_sso',
subject_email: 'sso@dify.ai',
subject_issuer: 'https://issuer.example',
account: null,
workspaces: [],
default_workspace_id: null,
token_id: 'tok-sso-1',
})
}

View File

@ -7,8 +7,6 @@ REPO_ROOT="$SCRIPT_DIR/.."
cd "$REPO_ROOT"
EXCLUDES_FILE="api/pyrefly-local-excludes.txt"
TEST_CONTAINERS_DIR="tests/test_containers_integration_tests"
TEST_CONTAINERS_CONFIG="$TEST_CONTAINERS_DIR/pyrefly.toml"
target_paths=()
for target_path in "$@"; do
@ -35,22 +33,7 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
done < "$EXCLUDES_FILE"
fi
run_pyrefly() {
local tmp_output
tmp_output="$(mktemp)"
set +e
"$@" >"$tmp_output" 2>&1
local pyrefly_status=$?
set -e
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
rm -f "$tmp_output"
return "$pyrefly_status"
}
status=0
tmp_output="$(mktemp)"
pyrefly_command=(
uv run --directory api --dev pyrefly check
"${pyrefly_args[@]}"
@ -59,15 +42,12 @@ if (( ${#target_paths[@]} > 0 )); then
pyrefly_command+=("${target_paths[@]}")
fi
run_pyrefly "${pyrefly_command[@]}" || status=$?
set +e
"${pyrefly_command[@]}" >"$tmp_output" 2>&1
pyrefly_status=$?
set -e
if (( ${#target_paths[@]} == 0 )); then
run_pyrefly \
uv run --directory api --dev pyrefly check \
"--summary=none" \
"--use-ignore-files=false" \
"--config=$TEST_CONTAINERS_CONFIG" \
|| status=$?
fi
uv run --directory api python libs/pyrefly_diagnostics.py < "$tmp_output"
rm -f "$tmp_output"
exit "$status"
exit "$pyrefly_status"

View File

@ -7,13 +7,9 @@ When('I open the publish panel', async function (this: DifyWorld) {
})
When('I publish the app', async function (this: DifyWorld) {
await this.getPage()
.getByRole('button', { name: /Publish Update/ })
.click()
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
})
Then('the app should be marked as published', async function (this: DifyWorld) {
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
timeout: 30_000,
})
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
})

View File

@ -1,21 +1,13 @@
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import {
createTestApp,
enableAppSiteAndGetURL,
publishWorkflowApp,
syncRunnableWorkflowDraft,
} from '../../../support/api'
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
When('I enable the Web App share', async function (this: DifyWorld) {
const page = this.getPage()
const appName = this.lastCreatedAppName
if (!appName) {
throw new Error(
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
)
}
if (!appName)
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
@ -36,11 +28,8 @@ Given('a workflow app has been published and shared via API', async function (th
})
When('I open the shared app URL', async function (this: DifyWorld) {
if (!this.shareURL) {
throw new Error(
'No share URL available. Run "a workflow app has been published and shared via API" first.',
)
}
if (!this.shareURL)
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
})

View File

@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage()
const testRunButton = page.getByRole('button', { name: /Test Run/ })
const testRunButton = page.getByText('Test Run')
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
await testRunButton.click()
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
Then('the workflow run should succeed', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByText('DETAIL', { exact: true }).click()
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
await page.getByText('DETAIL').click()
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
})

View File

@ -3693,11 +3693,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx": {
"react/static-components": {
"count": 2

View File

@ -12,9 +12,7 @@ import {
zGetAgentsByAgentIdVersionsByVersionIdResponse,
zGetAgentsByAgentIdVersionsPath,
zGetAgentsByAgentIdVersionsResponse,
zGetAgentsInviteOptionsQuery,
zGetAgentsInviteOptionsResponse,
zGetAgentsQuery,
zGetAgentsResponse,
zPatchAgentsByAgentIdBody,
zPatchAgentsByAgentIdPath,
@ -23,15 +21,22 @@ import {
zPostAgentsResponse,
} from './zod.gen'
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsInviteOptions',
path: '/agents/invite-options',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsInviteOptionsQuery.optional() }))
.output(zGetAgentsInviteOptionsResponse)
export const inviteOptions = {
@ -61,8 +66,16 @@ export const byVersionId = {
get: get2,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get3 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentIdVersions',
@ -77,20 +90,35 @@ export const versions = {
byVersionId,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const delete_ = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteAgentsByAgentId',
path: '/agents/{agent_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteAgentsByAgentIdPath }))
.output(zDeleteAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get4 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentsByAgentId',
@ -100,8 +128,16 @@ export const get4 = oc
.input(z.object({ params: zGetAgentsByAgentIdPath }))
.output(zGetAgentsByAgentIdResponse)
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const patch = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchAgentsByAgentId',
@ -118,15 +154,22 @@ export const byAgentId = {
versions,
}
/**
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const get5 = oc
.route({
deprecated: true,
description:
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgents',
path: '/agents',
tags: ['console'],
})
.input(z.object({ query: zGetAgentsQuery.optional() }))
.output(zGetAgentsResponse)
/**
@ -143,7 +186,6 @@ export const post = oc
method: 'POST',
operationId: 'postAgents',
path: '/agents',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ body: zPostAgentsBody }))

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