mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 06:06:20 +08:00
Compare commits
3 Commits
fix/cli-to
...
codex/migr
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b638a099 | |||
| 99d9a6f6a2 | |||
| 83f6e7daf9 |
@ -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 }}
|
||||
63
.github/workflows/style.yml
vendored
63
.github/workflows/style.yml
vendored
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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": []}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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
|
||||
|
||||
101
cli/src/auth/file-backend.test.ts
Normal file
101
cli/src/auth/file-backend.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
99
cli/src/auth/file-backend.ts
Normal file
99
cli/src/auth/file-backend.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
@ -1,202 +1,131 @@
|
||||
import type { Key, Store } from '../store/store.js'
|
||||
import type { AccountContext } from './hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts.js'
|
||||
import { FILE_PERM } from '../store/dir.js'
|
||||
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
describe('HostsBundleSchema', () => {
|
||||
it('parses a minimal logged-out bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({})
|
||||
expect(parsed.current_host).toBe('')
|
||||
expect(parsed.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
it('parses a logged-in keychain bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
expect(parsed.token_storage).toBe('keychain')
|
||||
expect(parsed.tokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
it('parses a logged-in file bundle with bearer', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_xxx' },
|
||||
})
|
||||
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
it('keeps available_workspaces when provided', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
available_workspaces: [
|
||||
{ id: 'a', name: 'A', role: 'owner' },
|
||||
{ id: 'b', name: 'B', role: 'member' },
|
||||
],
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
expect(parsed.available_workspaces).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
describe('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns an empty registry when nothing saved', () => {
|
||||
const reg = Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
it('returns undefined when file is missing', async () => {
|
||||
expect(await loadHosts(dir)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips a populated registry', () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
const loaded = Registry.load()
|
||||
it('round-trips bundle through YAML', async () => {
|
||||
await saveHosts(dir, {
|
||||
current_host: 'cloud.dify.ai',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
const loaded = await loadHosts(dir)
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('writes file with mode 0600', async () => {
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const info = await stat(join(dir, HOSTS_FILE_NAME))
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('rewrites permissive existing file with mode 0600', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 })
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const info = await stat(path)
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('atomic write: temp file does not survive on success', async () => {
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const { readdir } = await import('node:fs/promises')
|
||||
const entries = await readdir(dir)
|
||||
expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM })
|
||||
const loaded = await loadHosts(dir)
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect((loaded as Record<string, unknown> | undefined)?.future_field).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws on malformed YAML', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, ': : :\n', { mode: FILE_PERM })
|
||||
await expect(loadHosts(dir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when YAML contradicts schema', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM })
|
||||
await expect(loadHosts(dir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('produces YAML with stable keys', async () => {
|
||||
await saveHosts(dir, {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_x' },
|
||||
})
|
||||
const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8')
|
||||
expect(raw).toContain('current_host: cloud.dify.ai')
|
||||
expect(raw).toContain('bearer: dfoa_x')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { Store } from '../store/store.js'
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
import { getHostStore, tokenKey } from '../store/manager.js'
|
||||
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
|
||||
|
||||
export const HOSTS_FILE_NAME = 'hosts.yml'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
export type StorageMode = z.infer<typeof StorageModeSchema>
|
||||
@ -27,152 +29,72 @@ export const ExternalSubjectSchema = z.object({
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
export const TokensSchema = z.object({
|
||||
bearer: z.string(),
|
||||
})
|
||||
export type Tokens = z.infer<typeof TokensSchema>
|
||||
|
||||
export const HostsBundleSchema = z.object({
|
||||
current_host: z.string().default(''),
|
||||
scheme: z.string().optional(),
|
||||
account: AccountSchema.optional(),
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
available_workspaces: z.array(WorkspaceSchema).optional(),
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
tokens: TokensSchema.optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
export type HostsBundle = z.infer<typeof HostsBundleSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(path, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
||||
return undefined
|
||||
throw err
|
||||
}
|
||||
const parsed = yaml.load(raw)
|
||||
return HostsBundleSchema.parse(parsed ?? {})
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
|
||||
await mkdir(dir, { recursive: true, mode: DIR_PERM })
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false })
|
||||
const target = join(dir, HOSTS_FILE_NAME)
|
||||
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`
|
||||
try {
|
||||
await writeFile(tmp, body, { mode: FILE_PERM })
|
||||
await rename(tmp, target)
|
||||
}
|
||||
|
||||
static load(): Registry {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
const email = entry.current_account
|
||||
if (email === undefined || email === '')
|
||||
return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
catch (err) {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
await unlink(tmp)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
this.save()
|
||||
catch { /* tmp may not exist */ }
|
||||
throw err
|
||||
}
|
||||
|
||||
save(): void {
|
||||
getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
const { chmod, stat } = await import('node:fs/promises')
|
||||
try {
|
||||
const info = await stat(target)
|
||||
if ((info.mode & 0o777) !== FILE_PERM)
|
||||
await chmod(target, FILE_PERM)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
function stripUndefined<T extends Record<string, unknown>>(input: T): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v === undefined)
|
||||
continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
111
cli/src/auth/keyring-backend.test.ts
Normal file
111
cli/src/auth/keyring-backend.test.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
35
cli/src/auth/keyring-backend.ts
Normal file
35
cli/src/auth/keyring-backend.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
75
cli/src/auth/store.test.ts
Normal file
75
cli/src/auth/store.test.ts
Normal 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
40
cli/src/auth/store.ts
Normal 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' }
|
||||
}
|
||||
}
|
||||
31
cli/src/cache/app-info.test.ts
vendored
31
cli/src/cache/app-info.test.ts
vendored
@ -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(),
|
||||
|
||||
31
cli/src/cache/nudge-store.test.ts
vendored
31
cli/src/cache/nudge-store.test.ts
vendored
@ -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)
|
||||
})
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../cache/app-info.js'
|
||||
import type { Command } from '../../framework/command.js'
|
||||
import type { Store } from '../../store/store.js'
|
||||
import type { IOStreams } from '../../sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
|
||||
import { notLoggedInError, Registry } from '../../auth/hosts.js'
|
||||
import { loadHosts } from '../../auth/hosts.js'
|
||||
import { loadAppInfoCache } from '../../cache/app-info.js'
|
||||
import { loadNudgeStore } from '../../cache/nudge-store.js'
|
||||
import { getEnv } from '../../env/registry.js'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { formatErrorForCli } from '../../errors/format.js'
|
||||
import { createClient } from '../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../store/manager.js'
|
||||
import { resolveConfigDir } from '../../store/dir.js'
|
||||
import { realStreams } from '../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../util/host.js'
|
||||
import { versionInfo } from '../../version/info.js'
|
||||
@ -19,12 +20,11 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
|
||||
import { resolveRetryAttempts } from './global-flags.js'
|
||||
|
||||
export type AuthedContext = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
readonly configDir: string
|
||||
readonly cache?: AppInfoCache
|
||||
}
|
||||
|
||||
@ -38,31 +38,30 @@ export async function buildAuthedContext(
|
||||
cmd: Pick<Command, 'error'>,
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const configDir = resolveConfigDir()
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
const bundle = await loadHosts(configDir)
|
||||
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
const err = new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createClient({ host, bearer, retryAttempts })
|
||||
const host = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({
|
||||
flag: opts.retryFlag,
|
||||
env: getEnv,
|
||||
})
|
||||
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
return { bundle, http, host, io, configDir, cache }
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -1,50 +1,52 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { ActiveContext } from '../../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../../store/store.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../../auth/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import { Registry } from '../../../../auth/hosts.js'
|
||||
import { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../../store/dir.js'
|
||||
import { tokenKey } from '../../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../../sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: tokenId,
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
reg.setAccount(email)
|
||||
const active = reg.resolveActive()!
|
||||
return { reg, active }
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDevicesList', () => {
|
||||
@ -59,7 +61,7 @@ describe('runDevicesList', () => {
|
||||
it('table: marks current with *', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('DEVICE')
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
@ -72,29 +74,30 @@ describe('runDevicesList', () => {
|
||||
it('json: emits PaginationEnvelope unchanged', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.page).toBe(1)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
expect((parsed.data as unknown[]).length).toBe(3)
|
||||
})
|
||||
|
||||
it('not-logged-in: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesList({ io, bundle: undefined, http }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runDevicesRevoke', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -102,12 +105,12 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact device_label: revokes one + leaves local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
await store.put(b.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -115,30 +118,30 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact id: revokes one', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: unique match revokes', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: ambiguous throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -146,10 +149,10 @@ describe('runDevicesRevoke', () => {
|
||||
it('no match throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -157,33 +160,31 @@ describe('runDevicesRevoke', () => {
|
||||
it('--all: revokes everything except current', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, all: true })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
it('revoking current session clears token and removes context from registry', async () => {
|
||||
it('revoking current id clears local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
await store.put(b.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
const saved = Registry.load()
|
||||
expect(saved?.hosts[mock.url]).toBeUndefined()
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry } from '../../../../auth/hosts.js'
|
||||
import type { Store } from '../../../../store/store.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../../sys/io/streams'
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
@ -12,7 +15,7 @@ import { runWithSpinner } from '../../../../sys/io/spinner.js'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly tokenId: string
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
@ -21,6 +24,7 @@ export type DevicesListOptions = {
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -35,7 +39,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
@ -68,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
}
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
readonly store: TokenStore
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -80,6 +84,7 @@ export type DevicesRevokeOptions = {
|
||||
|
||||
export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const b = requireLogin(opts.bundle)
|
||||
if (!opts.all && (opts.target === undefined || opts.target === '')) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
@ -90,7 +95,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '')
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
@ -100,11 +105,22 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit)
|
||||
opts.reg.forget(opts.active, opts.store)
|
||||
await clearLocal(opts.configDir, b, opts.store)
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
|
||||
function requireLogin(b: HostsBundle | undefined): HostsBundle {
|
||||
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export type PickResult = {
|
||||
ids: readonly string[]
|
||||
selfHit: boolean
|
||||
@ -162,3 +178,18 @@ function renderTable(rows: readonly SessionRow[], currentId: string): string {
|
||||
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
|
||||
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
|
||||
}
|
||||
|
||||
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
await store.delete(bundle.current_host, accountId)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
try {
|
||||
await unlink(join(configDir, HOSTS_FILE_NAME))
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
tokenId: ctx.active.ctx.token_id ?? '',
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { selectStore } from '../../../../auth/store.js'
|
||||
import { Args, Flags } from '../../../../framework/flags.js'
|
||||
import { DifyCommand } from '../../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../../_shared/global-flags.js'
|
||||
@ -24,12 +25,13 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { args, flags } = this.parse(DevicesRevoke, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
const { store } = await selectStore({ configDir: ctx.configDir })
|
||||
await runDevicesRevoke({
|
||||
configDir: ctx.configDir,
|
||||
io: ctx.io,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
store: ctx.store,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
store,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
yes: flags.yes,
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { Clock } from './device-flow.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { tokenKey } from '../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogin } from './login.js'
|
||||
|
||||
@ -20,38 +18,38 @@ const noopClock: Clock = {
|
||||
|
||||
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => k.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
|
||||
describe('runLogin', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -59,7 +57,8 @@ describe('runLogin', () => {
|
||||
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -70,17 +69,16 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(bundle.account?.email).toBe('tester@dify.ai')
|
||||
expect(bundle.workspace?.id).toBe('ws-1')
|
||||
expect(bundle.available_workspaces).toHaveLength(2)
|
||||
const stored = await store.get(bundle.current_host, 'acct-1')
|
||||
expect(stored).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
expect(hostsRaw).toContain('tester@dify.ai')
|
||||
expect(hostsRaw).not.toContain('dfoa_test')
|
||||
expect(hostsRaw).not.toContain('bearer')
|
||||
|
||||
expect(io.outBuf()).toContain('Logged in to')
|
||||
expect(io.outBuf()).toContain('tester@dify.ai')
|
||||
@ -92,7 +90,8 @@ describe('runLogin', () => {
|
||||
mock.setScenario('sso')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -103,11 +102,10 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoe_test')
|
||||
expect(bundle.account).toBeUndefined()
|
||||
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
@ -117,6 +115,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -136,6 +135,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -148,28 +148,11 @@ describe('runLogin', () => {
|
||||
})).rejects.toThrow(/expired/)
|
||||
})
|
||||
|
||||
it('rejects login when the account has no email', async () => {
|
||||
mock.setScenario('no-email')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
insecure: true,
|
||||
deviceLabel: 'difyctl on test',
|
||||
api: new DeviceFlowApi(createClient({ host: mock.url })),
|
||||
store: { store, mode: 'file' },
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})).rejects.toThrow(/no email/i)
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects http:// host without --insecure', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -186,6 +169,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
|
||||
@ -1,37 +1,35 @@
|
||||
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
|
||||
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
|
||||
import type { StorageMode, Store } from '../../../store/store.js'
|
||||
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
|
||||
import type { Clock } from './device-flow.js'
|
||||
import * as os from 'node:os'
|
||||
import * as readline from 'node:readline'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { selectStore } from '../../../auth/store.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { startSpinner } from '../../../sys/io/spinner.js'
|
||||
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
|
||||
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
|
||||
import { awaitAuthorization, realClock } from './device-flow.js'
|
||||
|
||||
export type LoginOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly host?: string
|
||||
readonly noBrowser?: boolean
|
||||
readonly insecure?: boolean
|
||||
readonly deviceLabel?: string
|
||||
readonly store?: { readonly store: Store, readonly mode: StorageMode }
|
||||
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
|
||||
readonly api?: DeviceFlowApi
|
||||
readonly browserEnv?: BrowserEnv
|
||||
readonly browserOpener?: BrowserOpener
|
||||
readonly clock?: Clock
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const insecure = opts.insecure ?? false
|
||||
|
||||
@ -59,44 +57,22 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
|
||||
}
|
||||
|
||||
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
|
||||
let success: PollSuccess
|
||||
try {
|
||||
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
}
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.save()
|
||||
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
|
||||
await saveHosts(opts.configDir, bundle)
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return reg
|
||||
return bundle
|
||||
}
|
||||
|
||||
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
|
||||
let raw = opts.host?.trim() ?? ''
|
||||
if (raw === '') {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--host is required (no TTY)',
|
||||
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
|
||||
})
|
||||
}
|
||||
if (raw === '')
|
||||
raw = await promptHost(opts.io)
|
||||
}
|
||||
return resolveHost({ raw, insecure })
|
||||
}
|
||||
|
||||
@ -124,7 +100,7 @@ function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorS
|
||||
|
||||
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
|
||||
const display = bareHost(host)
|
||||
if (s.account && s.account.email !== '') {
|
||||
if (s.account !== undefined && s.account.email !== '') {
|
||||
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
|
||||
const ws = findDefaultWorkspace(s)
|
||||
if (ws !== undefined)
|
||||
@ -147,43 +123,50 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
|
||||
return s.workspaces?.find(w => w.id === s.default_workspace_id)
|
||||
}
|
||||
|
||||
function accountEmail(s: PollSuccess): string {
|
||||
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
|
||||
if (email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'account has no email; cannot store credential',
|
||||
hint: 'this Dify instance returned no email for the signed-in subject',
|
||||
})
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const ctx: AccountContext = {
|
||||
account: s.account
|
||||
? { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
: { id: '', email: '', name: '' },
|
||||
token_id: s.token_id,
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function applyScheme(reg: Registry, display: string, host: string): void {
|
||||
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
|
||||
const display = bareHost(host)
|
||||
let scheme: string | undefined
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
reg.setScheme(display, u.protocol.replace(':', ''))
|
||||
scheme = u.protocol.replace(':', '')
|
||||
}
|
||||
catch { /* keep scheme unset */ }
|
||||
catch { /* keep undefined */ }
|
||||
|
||||
const bundle: HostsBundle = {
|
||||
current_host: display,
|
||||
scheme,
|
||||
token_storage: mode,
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account !== undefined) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (s.account === undefined || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
}
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
bundle.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
}))
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
function accountKey(b: HostsBundle): string {
|
||||
if (b.account?.id !== undefined && b.account.id !== '')
|
||||
return b.account.id
|
||||
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
|
||||
return b.external_subject.email
|
||||
return 'default'
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { selectStore } from '../../../auth/store.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../../util/host.js'
|
||||
@ -17,21 +18,23 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const io = realStreams()
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
const { store } = await selectStore({ configDir })
|
||||
|
||||
let http: KyInstance | undefined
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
if (bearer !== '') {
|
||||
http = createClient({ host: hostWithScheme(active.host, active.scheme), bearer, retryAttempts: 0 })
|
||||
}
|
||||
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
|
||||
http = createClient({
|
||||
host: hostWithScheme(bundle.current_host, bundle.scheme),
|
||||
bearer: bundle.tokens.bearer,
|
||||
retryAttempts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, reg, http }),
|
||||
() => runLogout({ configDir, io, bundle, http, store }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,64 +1,143 @@
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => k.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureBundle(host: string): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seed(store: MemStore) {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
it('happy: revokes server side, clears local store + hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
seed(store)
|
||||
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
expect(io.errBuf()).toBe('')
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/i)
|
||||
it('not-logged-in: throws BaseError', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('hosts.yml absent: still completes locally + emits success', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
expect(io.errBuf()).toContain('server revoke failed')
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
bundle.tokens = { bearer: 'dfp_personal_token' }
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
|
||||
await saveHosts(configDir, bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(io.errBuf()).toBe('')
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves unrelated files in configDir', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
await saveHosts(configDir, bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,46 +1,70 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { AccountSessionsClient } from '../../../api/account-sessions.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { HOSTS_FILE_NAME } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
|
||||
export type LogoutOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly store: TokenStore
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = opts.reg
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
let revokeWarning = ''
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
|
||||
try {
|
||||
await new AccountSessionsClient(opts.http).revokeSelf()
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
await sessions.revokeSelf()
|
||||
}
|
||||
catch (err) {
|
||||
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
|
||||
}
|
||||
}
|
||||
|
||||
reg.forget(active, store)
|
||||
await clearLocal(opts.configDir, bundle, opts.store)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
await store.delete(bundle.current_host, accountId)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
const hostsPath = join(configDir, HOSTS_FILE_NAME)
|
||||
try {
|
||||
await unlink(hostsPath)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runStatus } from './status.js'
|
||||
@ -20,7 +21,8 @@ export default class Status extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const reg = Registry.load()
|
||||
await runStatus({ io: realStreams(), reg, verbose: flags.verbose, json: flags.json })
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,65 +1,49 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runStatus } from './status.js'
|
||||
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'keychain',
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'tester@dify.ai',
|
||||
accounts: {
|
||||
'tester@dify.ai': {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: 'tok-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
function ssoBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'sso@dify.ai',
|
||||
accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { id: '', email: '', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-sso-1',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runStatus', () => {
|
||||
it('logged-out: prints message + throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, reg: Registry.empty() })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
expect(io.outBuf()).toContain('Not logged in')
|
||||
})
|
||||
|
||||
it('logged-out json: emits {logged_in: false}', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, reg: Registry.empty(), json: true })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
|
||||
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
|
||||
})
|
||||
|
||||
it('account: human compact', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg() })
|
||||
await runStatus({ io, bundle: accountBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
|
||||
expect(out).toContain('Workspace: Default')
|
||||
@ -68,7 +52,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account verbose: shows ids + storage + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg(), verbose: true })
|
||||
await runStatus({ io, bundle: accountBundle(), verbose: true })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('Account:')
|
||||
@ -76,12 +60,11 @@ describe('runStatus', () => {
|
||||
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
|
||||
expect(out).toContain('Available: 2 workspaces')
|
||||
expect(out).toContain('Storage: keychain')
|
||||
expect(out).toContain('Contexts:')
|
||||
})
|
||||
|
||||
it('sso: human compact mentions issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: ssoReg() })
|
||||
await runStatus({ io, bundle: ssoBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
|
||||
expect(out).toContain('apps:run')
|
||||
@ -89,7 +72,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account json: matches schema with workspace + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg(), json: true })
|
||||
await runStatus({ io, bundle: accountBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.host).toBe('cloud.dify.ai')
|
||||
expect(parsed.logged_in).toBe(true)
|
||||
@ -101,7 +84,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: ssoReg(), json: true })
|
||||
await runStatus({ io, bundle: ssoBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.subject_type).toBe('external_sso')
|
||||
expect(parsed.subject_email).toBe('sso@dify.ai')
|
||||
|
||||
@ -1,94 +1,91 @@
|
||||
import type { AccountContext, Registry } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type StatusOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly verbose?: boolean
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runStatus(opts: StatusOptions): Promise<void> {
|
||||
const reg = opts.reg
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined) {
|
||||
if (opts.json === true)
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
|
||||
else
|
||||
}
|
||||
else {
|
||||
opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n')
|
||||
}
|
||||
throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' })
|
||||
}
|
||||
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${renderJson(active.host, active.ctx, reg.token_storage)}\n`)
|
||||
opts.io.out.write(`${renderJson(bundle)}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(renderHuman(active.host, active.ctx, reg.token_storage, opts.verbose ?? false))
|
||||
if (opts.verbose === true)
|
||||
opts.io.out.write(renderContexts(reg))
|
||||
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
|
||||
}
|
||||
|
||||
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
|
||||
function renderHuman(b: HostsBundle, verbose: boolean): string {
|
||||
const lines: string[] = []
|
||||
const sub = ctx.external_subject
|
||||
if (!verbose) {
|
||||
if (sub !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(sub.issuer !== ''
|
||||
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${host} as ${sub.email} (via SSO)`)
|
||||
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
|
||||
lines.push(' Scope: apps:run')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
lines.push(`Logged in to ${host} as ${ctx.account.email} (${ctx.account.name})`)
|
||||
if (ctx.workspace?.name !== undefined && ctx.workspace.name !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name}`)
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
|
||||
if (b.workspace?.name !== undefined && b.workspace.name !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name}`)
|
||||
lines.push(' Session: Dify account — full access')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
if (sub !== undefined) {
|
||||
lines.push(host)
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(b.current_host)
|
||||
lines.push(sub.issuer !== ''
|
||||
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
|
||||
: ` Subject: ${sub.email} (external SSO)`)
|
||||
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
|
||||
lines.push(` Storage: ${storage}`)
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
lines.push(host)
|
||||
lines.push(` Account: ${ctx.account.email} (${ctx.account.name}, ${ctx.account.id ?? ''})`)
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name} (${ctx.workspace.id}, role: ${ctx.workspace.role})`)
|
||||
lines.push(` Available: ${ctx.available_workspaces?.length ?? 0} workspaces`)
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(b.current_host)
|
||||
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
|
||||
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(' Session: Dify account — full access (scope: full)')
|
||||
lines.push(` Storage: ${storage}`)
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderContexts(reg: Registry): string {
|
||||
const lines = ['Contexts:']
|
||||
for (const [host, entry] of Object.entries(reg.hosts)) {
|
||||
for (const email of Object.keys(entry.accounts)) {
|
||||
const isActive = reg.current_host === host && entry.current_account === email
|
||||
lines.push(` ${isActive ? '*' : ' '} ${host} ${email}`)
|
||||
}
|
||||
function renderJson(b: HostsBundle): string {
|
||||
const out: Record<string, unknown> = {
|
||||
host: b.current_host,
|
||||
logged_in: true,
|
||||
storage: b.token_storage,
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(host: string, ctx: AccountContext, storage: string): string {
|
||||
const out: Record<string, unknown> = { host, logged_in: true, storage }
|
||||
if (ctx.external_subject !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = ctx.external_subject.email
|
||||
out.subject_issuer = ctx.external_subject.issuer
|
||||
out.subject_email = b.external_subject.email
|
||||
out.subject_issuer = b.external_subject.issuer
|
||||
}
|
||||
else {
|
||||
out.account = { id: ctx.account.id ?? '', email: ctx.account.email, name: ctx.account.name }
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
out.workspace = { id: ctx.workspace.id, name: ctx.workspace.name, role: ctx.workspace.role }
|
||||
out.available_workspaces_count = ctx.available_workspaces?.length ?? 0
|
||||
else if (b.account !== undefined) {
|
||||
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
|
||||
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
|
||||
}
|
||||
out.available_workspaces_count = b.available_workspaces?.length ?? 0
|
||||
}
|
||||
return JSON.stringify(out, null, 2)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runWhoami } from './whoami.js'
|
||||
@ -18,7 +19,8 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const reg = Registry.load()
|
||||
await runWhoami({ io: realStreams(), reg, json: flags.json })
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,82 +1,68 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runWhoami } from './whoami.js'
|
||||
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
|
||||
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { email: 'sso@dify.ai', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
} } },
|
||||
})
|
||||
token_storage: 'keychain',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runWhoami', () => {
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
|
||||
})
|
||||
|
||||
it('prints email + name for an account', async () => {
|
||||
it('logged-out: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toContain('a@b.c')
|
||||
expect(io.outBuf()).toContain('Ann')
|
||||
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('account human: emits "email (name)"', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
|
||||
await runWhoami({ io, bundle: accountBundle() })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
|
||||
})
|
||||
|
||||
it('account human, no name: emits email only', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = accountReg()
|
||||
reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = ''
|
||||
await runWhoami({ io, reg })
|
||||
expect(io.outBuf()).toBe('a@b.c\n')
|
||||
})
|
||||
|
||||
it('emits JSON when --json', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' })
|
||||
const b = accountBundle()
|
||||
b.account!.name = ''
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai\n')
|
||||
})
|
||||
|
||||
it('account json: emits {id, email, name}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
await runWhoami({ io, bundle: accountBundle(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
id: 'acct-1',
|
||||
email: 'a@b.c',
|
||||
name: 'Ann',
|
||||
email: 'tester@dify.ai',
|
||||
name: 'Test Tester',
|
||||
})
|
||||
})
|
||||
|
||||
it('sso human: emits email + issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg() })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
|
||||
})
|
||||
|
||||
it('sso json: emits {subject_type, email, issuer}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg(), json: true })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b, json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
subject_type: 'external_sso',
|
||||
email: 'sso@dify.ai',
|
||||
|
||||
@ -1,31 +1,46 @@
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type WhoamiOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
|
||||
const active = opts.reg.requireActive()
|
||||
const b = opts.bundle
|
||||
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
const sub = active.ctx.external_subject
|
||||
if (sub !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
|
||||
opts.io.out.write(`${JSON.stringify({
|
||||
subject_type: 'external_sso',
|
||||
email: b.external_subject.email,
|
||||
issuer: b.external_subject.issuer,
|
||||
})}\n`)
|
||||
return
|
||||
}
|
||||
const sub = b.external_subject
|
||||
opts.io.out.write(sub.issuer !== ''
|
||||
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
|
||||
: `${sub.email} (external SSO)\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const acc = active.ctx.account
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
|
||||
opts.io.out.write(acc.name !== ''
|
||||
? `${acc.email} (${acc.name})\n`
|
||||
: `${acc.email}\n`)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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() }))
|
||||
}
|
||||
}
|
||||
|
||||
14
cli/src/commands/config/path/run.test.ts
Normal file
14
cli/src/commands/config/path/run.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
10
cli/src/commands/config/path/run.ts
Normal file
10
cli/src/commands/config/path/run.ts
Normal 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`
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { formatted, OutputFormat } from '../../../framework/output.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
@ -24,7 +24,7 @@ export default class CreateMember extends DifyCommand {
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'inviter@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +35,7 @@ describe('runCreateMember', () => {
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -92,7 +91,7 @@ describe('runCreateMember', () => {
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runCreateMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { formatted, OutputFormat } from '../../../framework/output.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
@ -23,7 +23,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runDeleteMember', () => {
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -46,7 +45,7 @@ describe('runDeleteMember', () => {
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runDeleteMember', () => {
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -51,7 +51,7 @@ export async function runDeleteMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { formatted, OutputFormat } from '../../../framework/output.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
@ -20,7 +20,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
static override flags = {
|
||||
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
|
||||
'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }),
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
format,
|
||||
data: await runDescribeApp(
|
||||
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -8,49 +8,41 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { loadAppInfoCache } from '../../../cache/app-info.js'
|
||||
import { formatted, stringifyOutput } from '../../../framework/output.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDescribeApp', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
|
||||
}
|
||||
@ -90,16 +82,16 @@ describe('runDescribeApp', () => {
|
||||
})
|
||||
|
||||
it('refresh: bypasses cache', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const before = cache.get(mock.url, 'app-1')
|
||||
expect(before).toBeDefined()
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1', refresh: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const after = cache.get(mock.url, 'app-1')
|
||||
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
|
||||
@ -113,7 +105,7 @@ describe('runDescribeApp', () => {
|
||||
await expect(runDescribeApp(
|
||||
{ appId: 'nope' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
|
||||
}
|
||||
|
||||
export type DescribeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io?: IOStreams
|
||||
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { OutputFormat, table } from '../../../framework/output.js'
|
||||
import { table } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runGetApp } from './run.js'
|
||||
@ -42,7 +42,7 @@ export default class GetApp extends DifyCommand {
|
||||
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
|
||||
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
format,
|
||||
data: result.data,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
|
||||
import { AppListOutput } from './handlers.js'
|
||||
import { runGetApp } from './run.js'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetApp', () => {
|
||||
@ -37,7 +36,7 @@ describe('runGetApp', () => {
|
||||
}
|
||||
|
||||
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
|
||||
const result = await runGetApp(opts, { active: baseActive, http: http() })
|
||||
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
|
||||
return stringifyOutput(table({
|
||||
format: opts.format ?? '',
|
||||
data: result.data,
|
||||
@ -135,11 +134,7 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
|
||||
const minimal: ActiveContext = {
|
||||
host: 'h',
|
||||
email: 'x@x.com',
|
||||
ctx: { account: { email: 'x@x.com', name: 'X' } },
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
|
||||
await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppsClient } from '../../../api/apps.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
@ -24,7 +24,7 @@ export type GetAppOptions = {
|
||||
}
|
||||
|
||||
export type GetAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
return runAllWorkspaces(apps, ws, opts, page, pageSize)
|
||||
}
|
||||
if (opts.appId !== undefined && opts.appId !== '') {
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, wsId)
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsName = workspaceNameForId(deps.bundle, wsId)
|
||||
const desc = await apps.describe(opts.appId, wsId, ['info'])
|
||||
return describeToEnvelope(desc, wsId, wsName)
|
||||
}
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
@ -111,13 +111,12 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
function workspaceNameForId(b: HostsBundle, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (b.workspace?.id === id)
|
||||
return b.workspace.name
|
||||
for (const w of b.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { OutputFormat, table } from '../../../framework/output.js'
|
||||
import { table } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runGetMember } from './run.js'
|
||||
@ -23,7 +23,7 @@ export default class GetMember extends DifyCommand {
|
||||
'page': Flags.integer({ description: 'page number', default: 1 }),
|
||||
'limit': Flags.string({ description: 'page size [1..200]' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +37,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -55,7 +54,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -70,7 +69,7 @@ describe('runGetMember', () => {
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -79,20 +78,14 @@ describe('runGetMember', () => {
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when active context has no account id', async () => {
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const a: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: '', email: '', name: '' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
},
|
||||
}
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: a,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -103,16 +96,16 @@ describe('runGetMember', () => {
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
const noWs: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } },
|
||||
}
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
active: noWs,
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
@ -139,7 +132,7 @@ describe('MemberListOutput shape', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
|
||||
@ -16,7 +16,7 @@ export type GetMemberOptions = {
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -39,7 +39,7 @@ export async function runGetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -50,7 +50,7 @@ export async function runGetMember(
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.active.ctx.account?.id ?? ''
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { OutputFormat, raw, table } from '../../../framework/output.js'
|
||||
import { raw, table } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runGetWorkspace } from './run.js'
|
||||
@ -15,14 +15,14 @@ export default class GetWorkspace extends DifyCommand {
|
||||
|
||||
static override flags = {
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(GetWorkspace, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
if (result.kind === 'empty')
|
||||
return raw(result.message)
|
||||
return table({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
|
||||
import { WorkspaceListOutput } from './handlers.js'
|
||||
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetWorkspace', () => {
|
||||
@ -36,8 +35,8 @@ describe('runGetWorkspace', () => {
|
||||
return createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
}
|
||||
|
||||
async function render(format = '', activeCtx = baseActive): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
|
||||
async function render(format = '', bundle = baseBundle): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { bundle, http: http() })
|
||||
if (result.kind === 'empty')
|
||||
return result.message
|
||||
return stringifyOutput(table({
|
||||
@ -76,8 +75,8 @@ describe('runGetWorkspace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to active context workspace.id when server current=false', async () => {
|
||||
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } }
|
||||
it('falls back to bundle workspace.id when server current=false', async () => {
|
||||
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
|
||||
const out = await render('', overridden)
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('ws-2'))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type GetWorkspaceDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
|
||||
)
|
||||
if (env.workspaces.length === 0)
|
||||
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
|
||||
const currentId = deps.active.ctx.workspace?.id ?? ''
|
||||
const currentId = deps.bundle.workspace?.id ?? ''
|
||||
return {
|
||||
kind: 'output',
|
||||
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { OutputFormat } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { resumeApp } from './run.js'
|
||||
@ -26,7 +25,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
'with-history': Flags.boolean({ description: 'Replay executed-node history before attaching to live stream.', default: false }),
|
||||
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive. Default: collect and print at end.', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
}
|
||||
|
||||
@ -49,7 +48,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { RunContext } from '../../run/app/_strategies/index.js'
|
||||
@ -30,7 +30,7 @@ export type ResumeAppOptions = {
|
||||
}
|
||||
|
||||
export type ResumeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -78,7 +78,7 @@ async function resolveInputs(
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { OutputFormat } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { agentGuide } from './guide.js'
|
||||
@ -33,7 +32,7 @@ export default class RunApp extends DifyCommand {
|
||||
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
|
||||
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'Output format (json|yaml|text)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
@ -54,7 +53,7 @@ export default class RunApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -7,52 +7,44 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { loadAppInfoCache } from '../../../cache/app-info.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { resumeApp } from '../../resume/app/run.js'
|
||||
import { runApp } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runApp', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('chat: prints answer + conversation hint to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: hi\n')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -60,29 +52,29 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: rejects positional message with usage error', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
|
||||
})
|
||||
|
||||
it('workflow: prints single-string output as plain text', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
|
||||
it('json: passes through full envelope', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -93,7 +85,7 @@ describe('runApp', () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', format: 'bogus' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
@ -102,7 +94,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'nope', message: 'hi' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -112,10 +104,10 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('echo: ')
|
||||
expect(io.outBuf()).toContain('hi')
|
||||
@ -124,10 +116,10 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -137,10 +129,10 @@ describe('runApp', () => {
|
||||
|
||||
it('agent-chat without --stream: collects and prints answer', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('do research')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -148,10 +140,10 @@ describe('runApp', () => {
|
||||
|
||||
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('go')
|
||||
expect(io.errBuf()).toContain('thought:')
|
||||
@ -159,10 +151,10 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
|
||||
expect(parsed.mode).toBe('workflow')
|
||||
@ -172,22 +164,22 @@ describe('runApp', () => {
|
||||
it('stream-error scenario: error event surfaces typed BaseError', async () => {
|
||||
mock.setScenario('stream-error')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
})
|
||||
|
||||
it('--inputs-file: reads inputs from file', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const inputsFile = join(dir, 'inputs.json')
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -199,16 +191,16 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/must be a JSON object/)
|
||||
})
|
||||
|
||||
it('--inputs: accepts JSON object string', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -220,19 +212,19 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, '{}')
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsJson: '{}', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
|
||||
it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => {
|
||||
mock.setScenario('hitl-pause')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
let exitCode = -1
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -256,12 +248,12 @@ describe('runApp', () => {
|
||||
it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => {
|
||||
mock.setScenario('hitl-pause')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
let exitCode = -1
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -282,10 +274,10 @@ describe('runApp', () => {
|
||||
it('resume: withHistory: false completes successfully', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -293,10 +285,10 @@ describe('runApp', () => {
|
||||
it('resume: submits form and streams workflow to completion', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -304,10 +296,10 @@ describe('runApp', () => {
|
||||
it('resume --stream: live-prints workflow node progress to stderr', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
// stream mode for workflow: node_started → "→ <title>" on stderr
|
||||
expect(io.errBuf()).toContain('After Resume')
|
||||
@ -315,10 +307,10 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(0)
|
||||
@ -334,12 +326,12 @@ describe('runApp', () => {
|
||||
it('workflow: --file @path uploads file and passes local_file input variable', async () => {
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const filePath = join(dir, 'test.pdf')
|
||||
await writeFile(filePath, 'fake pdf content')
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: [`doc=@${filePath}`] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(1)
|
||||
@ -353,10 +345,10 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -32,7 +32,7 @@ export type RunAppOptions = {
|
||||
}
|
||||
|
||||
export type RunAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -80,7 +80,7 @@ async function resolveInputs(
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, wsId, [FieldInfo])
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Args, Flags } from '../../../framework/flags.js'
|
||||
import { formatted, OutputFormat } from '../../../framework/output.js'
|
||||
import { formatted } from '../../../framework/output.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../_shared/global-flags.js'
|
||||
import { runSetMember } from './run.js'
|
||||
@ -27,7 +27,7 @@ export default class SetMember extends DifyCommand {
|
||||
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
|
||||
}),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
|
||||
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runSetMember', () => {
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -47,7 +46,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -63,7 +62,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runSetMember', () => {
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type SetMemberOptions = {
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runSetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
|
||||
@ -26,8 +26,6 @@ import HelpExternal from './help/external/index.js'
|
||||
import ResumeApp from './resume/app/index.js'
|
||||
import RunApp from './run/app/index.js'
|
||||
import SetMember from './set/member/index.js'
|
||||
import UseAccount from './use/account/index.js'
|
||||
import UseHost from './use/host/index.js'
|
||||
import UseWorkspace from './use/workspace/index.js'
|
||||
import Version from './version/index.js'
|
||||
|
||||
@ -106,8 +104,6 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
account: { command: UseAccount, subcommands: {} },
|
||||
host: { command: UseHost, subcommands: {} },
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
export default class UseAccount extends DifyCommand {
|
||||
static override description = 'Switch the active account on the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use account',
|
||||
'<%= config.bin %> use account --email bob@corp.com',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
email: Flags.string({ description: 'account email to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseAccount, argv)
|
||||
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseAccount', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
it('errors when the account has no stored token', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
|
||||
.rejects
|
||||
.toThrow(/log in|no credential/i)
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when email omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
|
||||
})
|
||||
})
|
||||
@ -1,76 +0,0 @@
|
||||
import type { HostEntry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
|
||||
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
|
||||
|
||||
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
if (reg.current_host === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
const host = reg.current_host
|
||||
const entry = reg.hosts[host]
|
||||
if (entry === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
|
||||
const emails = Object.keys(entry.accounts)
|
||||
const target = opts.email ?? await pickAccount(opts, entry, host)
|
||||
if (!emails.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
hint: `run 'difyctl auth login --host ${host}'`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setAccount(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
|
||||
const emails = Object.keys(entry.accounts)
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
|
||||
email,
|
||||
name: ctx.account.name,
|
||||
sso: ctx.external_subject !== undefined,
|
||||
active: entry.current_account === email,
|
||||
}))
|
||||
const picked = await selectFromList<AccountChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: `Select an account on ${host}`,
|
||||
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
|
||||
})
|
||||
return picked.email
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
export default class UseHost extends DifyCommand {
|
||||
static override description = 'Switch the active Dify host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use host',
|
||||
'<%= config.bin %> use host --domain cloud.dify.ai',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
domain: Flags.string({ description: 'domain to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseHost, argv)
|
||||
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
describe('runUseHost', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_host when host is valid', async () => {
|
||||
await runUseHost({ io: bufferStreams(), host: 'h2' })
|
||||
expect(Registry.load().current_host).toBe('h2')
|
||||
})
|
||||
|
||||
it('errors when host is unknown, listing valid hosts', async () => {
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when host omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
|
||||
})
|
||||
|
||||
it('errors when no hosts exist', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
|
||||
})
|
||||
})
|
||||
@ -1,54 +0,0 @@
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseHostOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly host: string | undefined
|
||||
}
|
||||
|
||||
type HostChoice = { host: string, accounts: number, active: boolean }
|
||||
|
||||
export async function runUseHost(opts: UseHostOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
const hosts = Object.keys(reg.hosts)
|
||||
if (hosts.length === 0)
|
||||
throw notLoggedInError()
|
||||
|
||||
const target = opts.host ?? await pickHost(opts, reg, hosts)
|
||||
if (!hosts.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setHost(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: HostChoice[] = hosts.map(h => ({
|
||||
host: h,
|
||||
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
|
||||
active: reg.current_host === h,
|
||||
}))
|
||||
const picked = await selectFromList<HostChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: 'Select a host',
|
||||
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
|
||||
})
|
||||
return picked.host
|
||||
}
|
||||
@ -22,8 +22,8 @@ export default class UseWorkspace extends DifyCommand {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
configDir: ctx.configDir,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
|
||||
@ -3,36 +3,27 @@ import type {
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
return reg
|
||||
}
|
||||
|
||||
function makeActive(reg: Registry): ActiveContext {
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
throw new Error('resolveActive returned undefined in test setup')
|
||||
return active
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
@ -60,32 +51,24 @@ function fakeClient(opts: {
|
||||
describe('runUseWorkspace', () => {
|
||||
let configDir: string
|
||||
|
||||
let prevConfigDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -94,65 +77,40 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -162,8 +120,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -172,18 +130,16 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = Registry.load()
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -193,8 +149,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -202,15 +158,14 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = Registry.load()
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
await saveHosts(configDir, b)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -232,8 +187,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
@ -12,8 +13,8 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly configDir: string
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -31,12 +32,12 @@ export type UseWorkspaceDeps = {
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<Registry> {
|
||||
): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
@ -60,13 +61,16 @@ export async function runUseWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
}
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
await saveHosts(deps.configDir, next)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return deps.reg
|
||||
return next
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)', () => {
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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('')
|
||||
})
|
||||
})
|
||||
@ -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(', ')}` : '',
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { FormattedPrintable, NamePrintable, TablePrintable } from './output.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { OutputFormatNotSupportedError } from './errors.js'
|
||||
import {
|
||||
formatted,
|
||||
raw,
|
||||
@ -100,12 +99,13 @@ describe('stringifyOutput — formatted', () => {
|
||||
json: () => ({}),
|
||||
}
|
||||
const out = formatted({ format: 'name', data: noName })
|
||||
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
|
||||
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
|
||||
})
|
||||
|
||||
it('unknown format: throws with allowed list', () => {
|
||||
const out = formatted({ format: 'csv', data: makeFormatted({}) })
|
||||
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
|
||||
expect(() => stringifyOutput(out)).toThrow(/not supported/)
|
||||
expect(() => stringifyOutput(out)).toThrow(/json, name, text, yaml/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -175,12 +175,13 @@ describe('stringifyOutput — table', () => {
|
||||
json: () => [],
|
||||
}
|
||||
const out = table({ format: 'name', data: noName })
|
||||
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
|
||||
expect(() => stringifyOutput(out)).toThrow('name output requires data.name()')
|
||||
})
|
||||
|
||||
it('unknown format: throws with allowed list', () => {
|
||||
const out = table({ format: 'csv', data: makeTable({}) })
|
||||
expect(() => stringifyOutput(out)).toThrow(OutputFormatNotSupportedError)
|
||||
expect(() => stringifyOutput(out)).toThrow(/not supported/)
|
||||
expect(() => stringifyOutput(out)).toThrow(/json, name, wide, yaml/)
|
||||
})
|
||||
|
||||
it('table renders column padding correctly', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user