Compare commits

..

2 Commits

Author SHA1 Message Date
52cdaa20d7 chore: remove tag stauts into comp 2026-05-29 14:53:03 +08:00
ac4e117a2a chore: unified plugin status icon position 2026-05-29 14:37:48 +08:00
90 changed files with 1058 additions and 2106 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 { 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 { 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)
})
it('drops unknown top-level fields on parse', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
future_field: 42,
token_storage: 'file',
})
expect(parsed.current_host).toBe('cloud.dify.ai')
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
})
})
describe('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
let prevConfigDir: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
prev = process.env[ENV_CONFIG_DIR]
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prev === undefined)
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else process.env[ENV_CONFIG_DIR] = prev
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
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 nothing was saved', () => {
expect(loadHosts()).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 a fully-populated bundle', () => {
saveHosts({
current_host: 'cloud.dify.ai',
scheme: 'https',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'My Space', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'keychain',
token_id: 'tok_xyz',
})
const loaded = loadHosts()
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?.scheme).toBe('https')
expect(loaded?.account?.email).toBe('a@b.c')
expect(loaded?.workspace?.id).toBe('ws-1')
expect(loaded?.available_workspaces).toHaveLength(2)
expect(loaded?.token_storage).toBe('keychain')
expect(loaded?.token_id).toBe('tok_xyz')
})
it('round-trips a file-mode bundle with bearer token', () => {
saveHosts({
current_host: 'self.example.com',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
})
const loaded = loadHosts()
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
expect(loaded?.token_storage).toBe('file')
})
it('overwrites previous bundle on save', () => {
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
const loaded = loadHosts()
expect(loaded?.current_host).toBe('new.example.com')
expect(loaded?.token_storage).toBe('keychain')
})
it('rejects invalid input at save time', () => {
expect(() => saveHosts({
current_host: 'cloud.dify.ai',
token_storage: 'cloud',
} as never)).toThrow()
})
})

View File

@ -1,7 +1,5 @@
import type { Store } from '../store/store.js'
import { z } from 'zod'
import { BaseError } from '../errors/base.js'
import { ErrorCode } from '../errors/codes.js'
import { getHostStore, tokenKey } from '../store/manager.js'
const StorageModeSchema = z.enum(['keychain', 'file'])
@ -27,152 +25,42 @@ 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 function loadHosts(): HostsBundle | undefined {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return undefined
return HostsBundleSchema.parse(raw)
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
export function saveHosts(bundle: HostsBundle): void {
const validated = HostsBundleSchema.parse(bundle)
getHostStore().setTyped(validated)
}
export class Registry {
private readonly data: RegistryData
private constructor(data: RegistryData) {
this.data = data
}
static load(): Registry {
const raw = getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return Registry.empty()
return new Registry(RegistrySchema.parse(raw))
}
static empty(mode: StorageMode = 'file'): Registry {
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
}
static from(data: RegistryData): Registry {
return new Registry(data)
}
get hosts(): RegistryData['hosts'] { return this.data.hosts }
get current_host(): string | undefined { return this.data.current_host }
get token_storage(): StorageMode { return this.data.token_storage }
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
resolveActive(): ActiveContext | undefined {
const host = this.data.current_host
if (host === undefined || host === '')
return undefined
const entry = this.data.hosts[host]
if (entry === undefined)
return undefined
const email = entry.current_account
if (email === undefined || email === '')
return undefined
const ctx = entry.accounts[email]
if (ctx === undefined)
return undefined
return { host, email, ctx, scheme: entry.scheme }
}
requireActive(hint?: string): ActiveContext {
const active = this.resolveActive()
if (active === undefined)
throw notLoggedInError(hint)
return active
}
upsert(host: string, email: string, ctx: AccountContext): void {
const entry = this.data.hosts[host] ?? { accounts: {} }
entry.accounts[email] = ctx
this.data.hosts[host] = entry
}
remove(host: string, email: string): void {
const entry = this.data.hosts[host]
if (entry === undefined)
return
const wasActive = entry.current_account === email
delete entry.accounts[email]
if (wasActive)
entry.current_account = undefined
if (Object.keys(entry.accounts).length === 0) {
delete this.data.hosts[host]
if (this.data.current_host === host)
this.data.current_host = undefined
}
else if (wasActive && this.data.current_host === host) {
this.data.current_host = undefined
}
}
setHost(host: string): void {
this.data.current_host = host
}
setAccount(email: string): void {
const host = this.data.current_host
if (host === undefined)
return
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.current_account = email
}
setScheme(host: string, scheme: string): void {
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.scheme = scheme
}
activate(host: string, email: string, ctx: AccountContext): void {
this.upsert(host, email, ctx)
this.setHost(host)
this.setAccount(email)
}
// Teardown for "this credential is gone": drop the token, drop the context
// (unsets pointers when active), persist. Logout + self-revoke share it.
forget(active: ActiveContext, store: Store): void {
try {
store.unset(tokenKey(active.host, active.email))
}
catch { /* best-effort */ }
this.remove(active.host, active.email)
this.save()
}
save(): void {
getHostStore().setTyped(RegistrySchema.parse(this.data))
export function clearLocal(bundle: HostsBundle, store: Store): void {
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
try {
store.unset(tokenKey(bundle.current_host, accountId))
}
catch { /* best-effort */ }
getHostStore().rm()
}

View File

@ -1,17 +1,17 @@
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 { realStreams } from '../../sys/io/streams'
import { hostWithScheme } from '../../util/host.js'
import { versionInfo } from '../../version/info.js'
@ -19,9 +19,7 @@ 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
@ -39,30 +37,28 @@ export async function buildAuthedContext(
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const io = realStreams(opts.format ?? '')
const reg = Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
const bundle = loadHosts()
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
}
const { 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, cache }
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -1,16 +1,16 @@
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
import type { ActiveContext } from '../../../../auth/hosts.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { Key, Store } from '../../../../store/store.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, 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 { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
import { tokenKey } from '../../../../store/manager.js'
import { bufferStreams } from '../../../../sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
@ -30,21 +30,20 @@ class MemStore implements Store {
}
}
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 +58,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,12 +71,20 @@ 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', () => {
@ -102,12 +109,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')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(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({ 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 +122,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({ 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({ 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({ io, bundle: b, http, store, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -146,10 +153,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({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -157,33 +164,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({ 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')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
await runDevicesRevoke({ 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(resolveConfigDir(), '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({ io, bundle: bundleFor(mock.url), http, store, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

@ -1,18 +1,20 @@
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 { HostsBundle } from '../../../../auth/hosts.js'
import type { Store } from '../../../../store/store.js'
import type { IOStreams } from '../../../../sys/io/streams'
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
import { clearLocal } from '../../../../auth/hosts.js'
import { BaseError } from '../../../../errors/base.js'
import { ErrorCode } from '../../../../errors/codes.js'
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
import { getTokenStore } from '../../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
import { runWithSpinner } from '../../../../sys/io/spinner.js'
export type DevicesListOptions = {
readonly io: IOStreams
readonly tokenId: string
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
readonly json?: boolean
readonly page?: number
@ -21,6 +23,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 +38,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 {
@ -69,10 +72,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
export type DevicesRevokeOptions = {
readonly io: IOStreams
readonly reg: Registry
readonly active: ActiveContext
readonly store: Store
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
readonly target?: string
readonly all: boolean
readonly yes?: boolean
@ -80,6 +83,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 +94,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
@ -99,12 +103,25 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
for (const id of ids)
await sessions.revoke(id)
if (selfHit)
opts.reg.forget(opts.active, opts.store)
if (selfHit) {
const tokens = opts.store ?? getTokenStore().store
clearLocal(b, tokens)
}
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
function requireLogin(b: HostsBundle | undefined): HostsBundle {
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
}
return b
}
export type PickResult = {
ids: readonly string[]
selfHit: boolean

View File

@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
await runDevicesList({
io: ctx.io,
tokenId: ctx.active.ctx.token_id ?? '',
bundle: ctx.bundle,
http: ctx.http,
json: flags.json,
page: flags.page,

View File

@ -26,9 +26,7 @@ export default class DevicesRevoke extends DifyCommand {
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
await runDevicesRevoke({
io: ctx.io,
reg: ctx.reg,
active: ctx.active,
store: ctx.store,
bundle: ctx.bundle,
http: ctx.http,
target: args.target,
all: flags.all,

View File

@ -59,7 +59,7 @@ describe('runLogin', () => {
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
const bundle = await runLogin({
io,
host: mock.url,
noBrowser: true,
@ -70,17 +70,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 = store.get(tokenKey(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 +91,7 @@ describe('runLogin', () => {
mock.setScenario('sso')
const io = bufferStreams()
const store = new MemStore()
const reg = await runLogin({
const bundle = await runLogin({
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')
})
@ -148,24 +146,6 @@ describe('runLogin', () => {
})).rejects.toThrow(/expired/)
})
it('rejects login when the account has no email', async () => {
mock.setScenario('no-email')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(createClient({ host: mock.url })),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/no email/i)
expect(store.entries.size).toBe(0)
})
it('rejects http:// host without --insecure', async () => {
const io = bufferStreams()
const store = new MemStore()

View File

@ -1,5 +1,5 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
@ -7,13 +7,10 @@ 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 { 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'
@ -31,7 +28,7 @@ export type LoginOptions = {
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 +56,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 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()
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(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 +99,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 +122,50 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
return s.workspaces?.find(w => w.id === s.default_workspace_id)
}
function accountEmail(s: PollSuccess): string {
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
if (email === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'account has no email; cannot store credential',
hint: 'this Dify instance returned no email for the signed-in subject',
})
}
return email
}
function contextFromSuccess(s: PollSuccess): AccountContext {
const ctx: AccountContext = {
account: s.account
? { id: s.account.id, email: s.account.email, name: s.account.name }
: { id: '', email: '', name: '' },
token_id: s.token_id,
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (!s.account || s.account.id === '')) {
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
ctx.workspace = def
if (s.workspaces !== undefined && s.workspaces.length > 0) {
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
}
return ctx
}
function applyScheme(reg: Registry, display: string, host: string): void {
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
const display = bareHost(host)
let scheme: string | undefined
try {
const u = new URL(host)
if (u.protocol !== 'https:')
reg.setScheme(display, u.protocol.replace(':', ''))
scheme = u.protocol.replace(':', '')
}
catch { /* keep scheme unset */ }
catch { /* keep undefined */ }
const bundle: HostsBundle = {
current_host: display,
scheme,
token_storage: mode,
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account !== undefined) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (s.account === undefined || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',
}
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
bundle.workspace = def
if (s.workspaces !== undefined && s.workspaces.length > 0) {
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
id: w.id,
name: w.name,
role: w.role,
}))
}
return bundle
}
function accountKey(b: HostsBundle): string {
if (b.account?.id !== undefined && b.account.id !== '')
return b.account.id
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
return b.external_subject.email
return 'default'
}

View File

@ -1,7 +1,6 @@
import type { KyInstance } from 'ky'
import { Registry } from '../../../auth/hosts.js'
import { loadHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
import { realStreams } from '../../../sys/io/streams'
import { hostWithScheme } from '../../../util/host.js'
@ -17,21 +16,21 @@ 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 bundle = loadHosts()
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({ io, bundle, http }),
)
}
}

View File

@ -1,64 +1,145 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Key, Store } from '../../../store/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
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 { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogout } from './logout.js'
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
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) }
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
function fixtureBundle(host: string): HostsBundle {
return {
current_host: host,
scheme: 'http',
token_storage: 'file',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
describe('runLogout', () => {
let dir: string
let prev: string | undefined
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
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-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
})
afterEach(async () => {
if (prev === undefined)
if (prevConfigDir === undefined)
delete process.env[ENV_CONFIG_DIR]
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
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)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
expect(io.outBuf()).toContain('Logged out of')
expect(io.errBuf()).toBe('')
})
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({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
expect(io.outBuf()).toContain('Logged out of')
})
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
})
it('preserves unrelated files in configDir', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
saveHosts(bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')
})
})

View File

@ -1,46 +1,54 @@
import type { KyInstance } from 'ky'
import type { Registry } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AccountSessionsClient } from '../../../api/account-sessions.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { clearLocal } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { getTokenStore } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
export type LogoutOptions = {
readonly io: IOStreams
readonly reg: Registry
readonly bundle: HostsBundle | undefined
readonly http?: KyInstance
/** Optional override for tests; production resolves via `getTokenStore`. */
/** Optional override for tests; production code resolves via `getTokenStore`. */
readonly store?: Store
}
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
export async function runLogout(opts: LogoutOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const 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)
const tokens = opts.store ?? getTokenStore().store
clearLocal(bundle, tokens)
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))
}

View File

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

View File

@ -1,65 +1,49 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it } from 'vitest'
import { Registry } from '../../../auth/hosts.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runStatus } from './status.js'
function accountReg(): Registry {
return Registry.from({
token_storage: 'keychain',
function accountBundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
hosts: {
'cloud.dify.ai': {
current_account: 'tester@dify.ai',
accounts: {
'tester@dify.ai': {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_id: 'tok-1',
},
},
},
},
})
token_storage: 'keychain',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
function ssoReg(): Registry {
return Registry.from({
token_storage: 'file',
function ssoBundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
hosts: {
'cloud.dify.ai': {
current_account: 'sso@dify.ai',
accounts: {
'sso@dify.ai': {
account: { id: '', email: '', name: '' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
},
},
},
},
})
token_storage: 'file',
token_id: 'tok-sso-1',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
}
describe('runStatus', () => {
it('logged-out: prints message + throws NotLoggedIn', async () => {
const io = bufferStreams()
await expect(runStatus({ io, reg: Registry.empty() })).rejects.toThrow(/not logged in/)
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
expect(io.outBuf()).toContain('Not logged in')
})
it('logged-out json: emits {logged_in: false}', async () => {
const io = bufferStreams()
await expect(runStatus({ io, reg: Registry.empty(), json: true })).rejects.toThrow(/not logged in/)
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
})
it('account: human compact', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg() })
await runStatus({ io, bundle: accountBundle() })
const out = io.outBuf()
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
expect(out).toContain('Workspace: Default')
@ -68,7 +52,7 @@ describe('runStatus', () => {
it('account verbose: shows ids + storage + workspace count', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg(), verbose: true })
await runStatus({ io, bundle: accountBundle(), verbose: true })
const out = io.outBuf()
expect(out).toContain('cloud.dify.ai')
expect(out).toContain('Account:')
@ -76,12 +60,11 @@ describe('runStatus', () => {
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
expect(out).toContain('Available: 2 workspaces')
expect(out).toContain('Storage: keychain')
expect(out).toContain('Contexts:')
})
it('sso: human compact mentions issuer', async () => {
const io = bufferStreams()
await runStatus({ io, reg: ssoReg() })
await runStatus({ io, bundle: ssoBundle() })
const out = io.outBuf()
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
expect(out).toContain('apps:run')
@ -89,7 +72,7 @@ describe('runStatus', () => {
it('account json: matches schema with workspace + workspace count', async () => {
const io = bufferStreams()
await runStatus({ io, reg: accountReg(), json: true })
await runStatus({ io, bundle: accountBundle(), json: true })
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
expect(parsed.host).toBe('cloud.dify.ai')
expect(parsed.logged_in).toBe(true)
@ -101,7 +84,7 @@ describe('runStatus', () => {
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
const io = bufferStreams()
await runStatus({ io, reg: ssoReg(), json: true })
await runStatus({ io, bundle: ssoBundle(), json: true })
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
expect(parsed.subject_type).toBe('external_sso')
expect(parsed.subject_email).toBe('sso@dify.ai')

View File

@ -1,94 +1,91 @@
import type { AccountContext, Registry } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
export type StatusOptions = {
readonly io: IOStreams
readonly reg: Registry
readonly bundle: HostsBundle | undefined
readonly verbose?: boolean
readonly json?: boolean
}
export async function runStatus(opts: StatusOptions): Promise<void> {
const reg = opts.reg
const active = reg.resolveActive()
if (active === undefined) {
if (opts.json === true)
const bundle = opts.bundle
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
else
}
else {
opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n')
}
throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' })
}
if (opts.json === true) {
opts.io.out.write(`${renderJson(active.host, active.ctx, reg.token_storage)}\n`)
opts.io.out.write(`${renderJson(bundle)}\n`)
return
}
opts.io.out.write(renderHuman(active.host, active.ctx, reg.token_storage, opts.verbose ?? false))
if (opts.verbose === true)
opts.io.out.write(renderContexts(reg))
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
}
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
function renderHuman(b: HostsBundle, verbose: boolean): string {
const lines: string[] = []
const sub = ctx.external_subject
if (!verbose) {
if (sub !== undefined) {
if (b.external_subject !== undefined) {
const sub = b.external_subject
lines.push(sub.issuer !== ''
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
: `Logged in to ${host} as ${sub.email} (via SSO)`)
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
lines.push(' Scope: apps:run')
return `${lines.join('\n')}\n`
}
lines.push(`Logged in to ${host} as ${ctx.account.email} (${ctx.account.name})`)
if (ctx.workspace?.name !== undefined && ctx.workspace.name !== '')
lines.push(` Workspace: ${ctx.workspace.name}`)
const acc = b.account ?? { id: '', email: '', name: '' }
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
if (b.workspace?.name !== undefined && b.workspace.name !== '')
lines.push(` Workspace: ${b.workspace.name}`)
lines.push(' Session: Dify account — full access')
return `${lines.join('\n')}\n`
}
if (sub !== undefined) {
lines.push(host)
if (b.external_subject !== undefined) {
const sub = b.external_subject
lines.push(b.current_host)
lines.push(sub.issuer !== ''
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
: ` Subject: ${sub.email} (external SSO)`)
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
lines.push(` Storage: ${storage}`)
lines.push(` Storage: ${b.token_storage}`)
return `${lines.join('\n')}\n`
}
lines.push(host)
lines.push(` Account: ${ctx.account.email} (${ctx.account.name}, ${ctx.account.id ?? ''})`)
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
lines.push(` Workspace: ${ctx.workspace.name} (${ctx.workspace.id}, role: ${ctx.workspace.role})`)
lines.push(` Available: ${ctx.available_workspaces?.length ?? 0} workspaces`)
const acc = b.account ?? { id: '', email: '', name: '' }
lines.push(b.current_host)
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
if (b.workspace?.id !== undefined && b.workspace.id !== '')
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
lines.push(' Session: Dify account — full access (scope: full)')
lines.push(` Storage: ${storage}`)
lines.push(` Storage: ${b.token_storage}`)
return `${lines.join('\n')}\n`
}
function renderContexts(reg: Registry): string {
const lines = ['Contexts:']
for (const [host, entry] of Object.entries(reg.hosts)) {
for (const email of Object.keys(entry.accounts)) {
const isActive = reg.current_host === host && entry.current_account === email
lines.push(` ${isActive ? '*' : ' '} ${host} ${email}`)
}
function renderJson(b: HostsBundle): string {
const out: Record<string, unknown> = {
host: b.current_host,
logged_in: true,
storage: b.token_storage,
}
return `${lines.join('\n')}\n`
}
function renderJson(host: string, ctx: AccountContext, storage: string): string {
const out: Record<string, unknown> = { host, logged_in: true, storage }
if (ctx.external_subject !== undefined) {
if (b.external_subject !== undefined) {
out.subject_type = 'external_sso'
out.subject_email = ctx.external_subject.email
out.subject_issuer = ctx.external_subject.issuer
out.subject_email = b.external_subject.email
out.subject_issuer = b.external_subject.issuer
}
else {
out.account = { id: ctx.account.id ?? '', email: ctx.account.email, name: ctx.account.name }
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
out.workspace = { id: ctx.workspace.id, name: ctx.workspace.name, role: ctx.workspace.role }
out.available_workspaces_count = ctx.available_workspaces?.length ?? 0
else if (b.account !== undefined) {
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
}
out.available_workspaces_count = b.available_workspaces?.length ?? 0
}
return JSON.stringify(out, null, 2)
}

View File

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

View File

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

View File

@ -1,31 +1,46 @@
import type { Registry } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
export type WhoamiOptions = {
readonly io: IOStreams
readonly reg: Registry
readonly bundle: HostsBundle | undefined
readonly json?: boolean
}
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
const active = opts.reg.requireActive()
const b = opts.bundle
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
}
const sub = active.ctx.external_subject
if (sub !== undefined) {
if (b.external_subject !== undefined) {
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
opts.io.out.write(`${JSON.stringify({
subject_type: 'external_sso',
email: b.external_subject.email,
issuer: b.external_subject.issuer,
})}\n`)
return
}
const sub = b.external_subject
opts.io.out.write(sub.issuer !== ''
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
: `${sub.email} (external SSO)\n`)
return
}
const acc = active.ctx.account
const acc = b.account ?? { id: '', email: '', name: '' }
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
return
}
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
opts.io.out.write(acc.name !== ''
? `${acc.email} (${acc.name})\n`
: `${acc.email}\n`)
}

View File

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

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runCreateMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
host: 'cloud.dify.ai',
email: 'inviter@example.com',
ctx: {
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
@ -36,7 +35,7 @@ describe('runCreateMember', () => {
const result = await runCreateMember(
{ email: 'new@example.com', role: 'normal' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -61,7 +60,7 @@ describe('runCreateMember', () => {
runCreateMember(
{ email: 'new@example.com', role: 'owner' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -77,7 +76,7 @@ describe('runCreateMember', () => {
runCreateMember(
{ email: '', role: 'normal' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -92,7 +91,7 @@ describe('runCreateMember', () => {
await runCreateMember(
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
}
export type CreateMemberDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
@ -59,7 +59,7 @@ export async function runCreateMember(
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
bundle: deps.bundle,
})
const response = await runWithSpinner(

View File

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

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runDeleteMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
host: 'cloud.dify.ai',
email: 'me@example.com',
ctx: {
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
@ -28,7 +27,7 @@ describe('runDeleteMember', () => {
const result = await runDeleteMember(
{ memberId: 'acct-2' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -46,7 +45,7 @@ describe('runDeleteMember', () => {
await runDeleteMember(
{ memberId: 'acct-2', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -61,7 +60,7 @@ describe('runDeleteMember', () => {
runDeleteMember(
{ memberId: '' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import * as readline from 'node:readline'
import { MembersClient } from '../../../api/members.js'
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
}
export type DeleteMemberDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
@ -51,7 +51,7 @@ export async function runDeleteMember(
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
bundle: deps.bundle,
})
if (!opts.yes && io.isErrTTY) {

View File

@ -1,5 +1,5 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted, OutputFormat } from '../../../framework/output.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDescribeApp } from './run.js'
@ -20,7 +20,7 @@ export default class DescribeApp extends DifyCommand {
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|text)', default: '' }),
'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }),
}
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
format,
data: await runDescribeApp(
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
),
})
}

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -12,18 +12,17 @@ import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.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' },
],
}
}
@ -50,7 +49,7 @@ describe('runDescribeApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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 }))
}
@ -93,13 +92,13 @@ describe('runDescribeApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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 +112,7 @@ describe('runDescribeApp', () => {
await expect(runDescribeApp(
{ appId: 'nope' },
{
active: active(),
bundle: bundle(),
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
},

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppMetaClient } from '../../../api/app-meta.js'
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
}
export type DescribeAppDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly host: string
readonly io?: IOStreams
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const io = deps.io ?? nullStreams()

View File

@ -1,6 +1,6 @@
import type { AppMode } from '@dify/contracts/api/openapi/types.gen'
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat, table } from '../../../framework/output.js'
import { table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetApp } from './run.js'
@ -42,7 +42,7 @@ export default class GetApp extends DifyCommand {
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
name: flags.name,
tag: flags.tag,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
return table({
format,
data: result.data,

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { stringifyOutput, table } from '../../../framework/output.js'
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
import { AppListOutput } from './handlers.js'
import { runGetApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
const baseBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
}
describe('runGetApp', () => {
@ -37,7 +36,7 @@ describe('runGetApp', () => {
}
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
const result = await runGetApp(opts, { active: baseActive, http: http() })
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
return stringifyOutput(table({
format: opts.format ?? '',
data: result.data,
@ -135,11 +134,7 @@ describe('runGetApp', () => {
})
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
const minimal: ActiveContext = {
host: 'h',
email: 'x@x.com',
ctx: { account: { email: 'x@x.com', name: 'X' } },
}
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/)
})
})

View File

@ -1,6 +1,6 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppsClient } from '../../../api/apps.js'
import { WorkspacesClient } from '../../../api/workspaces.js'
@ -24,7 +24,7 @@ export type GetAppOptions = {
}
export type GetAppDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
return runAllWorkspaces(apps, ws, opts, page, pageSize)
}
if (opts.appId !== undefined && opts.appId !== '') {
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = workspaceNameForId(deps.active, wsId)
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const wsName = workspaceNameForId(deps.bundle, wsId)
const desc = await apps.describe(opts.appId, wsId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
return apps.list({
workspaceId: wsId,
page,
@ -111,13 +111,12 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
}
}
function workspaceNameForId(active: ActiveContext, id: string): string {
function workspaceNameForId(b: HostsBundle, id: string): string {
if (id === '')
return ''
const ctx = active.ctx
if (ctx.workspace?.id === id)
return ctx.workspace.name
for (const w of ctx.available_workspaces ?? []) {
if (b.workspace?.id === id)
return b.workspace.name
for (const w of b.available_workspaces ?? []) {
if (w.id === id)
return w.name
}

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
@ -16,7 +16,7 @@ export type GetMemberOptions = {
}
export type GetMemberDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
@ -39,7 +39,7 @@ export async function runGetMember(
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
bundle: deps.bundle,
})
const limit = resolveLimit(opts.limitRaw, env)
@ -50,7 +50,7 @@ export async function runGetMember(
() => factory(deps.http).list(wsId, { page, limit }),
)
const callerId = deps.active.ctx.account?.id ?? ''
const callerId = deps.bundle.account?.id ?? ''
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
}

View File

@ -1,5 +1,5 @@
import { Flags } from '../../../framework/flags.js'
import { OutputFormat, raw, table } from '../../../framework/output.js'
import { raw, table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetWorkspace } from './run.js'
@ -15,14 +15,14 @@ export default class GetWorkspace extends DifyCommand {
static override flags = {
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
const { flags } = this.parse(GetWorkspace, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
if (result.kind === 'empty')
return raw(result.message)
return table({

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { stringifyOutput, table } from '../../../framework/output.js'
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
import { WorkspaceListOutput } from './handlers.js'
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
const baseBundle: HostsBundle = {
current_host: '127.0.0.1',
scheme: 'http',
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
}
describe('runGetWorkspace', () => {
@ -36,8 +35,8 @@ describe('runGetWorkspace', () => {
return createClient({ host: mock.url, bearer: 'dfoa_test' })
}
async function render(format = '', activeCtx = baseActive): Promise<string> {
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
async function render(format = '', bundle = baseBundle): Promise<string> {
const result = await runGetWorkspace({ format }, { bundle, http: http() })
if (result.kind === 'empty')
return result.message
return stringifyOutput(table({
@ -76,8 +75,8 @@ describe('runGetWorkspace', () => {
}
})
it('falls back to active context workspace.id when server current=false', async () => {
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } }
it('falls back to bundle workspace.id when server current=false', async () => {
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
const out = await render('', overridden)
for (const line of out.split('\n')) {
if (line.includes('ws-2'))

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { runWithSpinner } from '../../../sys/io/spinner.js'
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
}
export type GetWorkspaceDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
)
if (env.workspaces.length === 0)
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
const currentId = deps.active.ctx.workspace?.id ?? ''
const currentId = deps.bundle.workspace?.id ?? ''
return {
kind: 'output',
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(

View File

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

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { RunContext } from '../../run/app/_strategies/index.js'
@ -30,7 +30,7 @@ export type ResumeAppOptions = {
}
export type ResumeAppDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
@ -78,7 +78,7 @@ async function resolveInputs(
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })

View File

@ -1,5 +1,4 @@
import { Args, Flags } from '../../../framework/flags.js'
import { OutputFormat } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { agentGuide } from './guide.js'
@ -33,7 +32,7 @@ export default class RunApp extends DifyCommand {
'stream': Flags.boolean({ description: 'Print output live as tokens/events arrive (default: collect and print at end)', default: false }),
'think': Flags.boolean({ description: 'Show model thinking/reasoning when available. Strips <think>...</think> blocks silently by default; with --think, thinking is printed to stderr.', default: false }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'output': Flags.string({ char: 'o', description: 'Output format (json|yaml|text)', default: '' }),
}
async run(argv: string[]): Promise<void> {
@ -54,7 +53,7 @@ export default class RunApp extends DifyCommand {
stream: flags.stream,
think: flags.think,
},
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
)
}

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -13,18 +13,17 @@ 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' },
],
}
}
@ -52,7 +51,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
@ -63,7 +62,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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' })
})
@ -72,7 +71,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
})
@ -82,7 +81,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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 +92,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 +101,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,
@ -115,7 +114,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
@ -127,7 +126,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
@ -140,7 +139,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
@ -151,7 +150,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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:')
@ -162,7 +161,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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')
@ -175,7 +174,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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' })
})
@ -187,7 +186,7 @@ describe('runApp', () => {
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,7 +198,7 @@ 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/)
})
@ -208,7 +207,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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,7 +219,7 @@ 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/)
})
@ -232,7 +231,7 @@ describe('runApp', () => {
await expect(runApp(
{ appId: 'app-2', inputs: {} },
{
active: active(),
bundle: bundle(),
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
host: mock.url,
io,
@ -261,7 +260,7 @@ describe('runApp', () => {
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,
@ -285,7 +284,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
{ 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')
})
@ -296,7 +295,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ 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')
})
@ -307,7 +306,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
{ 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')
@ -318,7 +317,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(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)
@ -339,7 +338,7 @@ describe('runApp', () => {
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)
@ -356,7 +355,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { AppInfoCache } from '../../../cache/app-info.js'
import type { IOStreams } from '../../../sys/io/streams'
import { AppMetaClient } from '../../../api/app-meta.js'
@ -32,7 +32,7 @@ export type RunAppOptions = {
}
export type RunAppDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
@ -80,7 +80,7 @@ async function resolveInputs(
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
const env = deps.envLookup ?? getEnv
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
const apps = new AppsClient(deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const m = await meta.get(opts.appId, wsId, [FieldInfo])

View File

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

View File

@ -1,18 +1,17 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../sys/io/streams'
import { runSetMember } from './run.js'
function active(): ActiveContext {
function bundle(): HostsBundle {
return {
host: 'cloud.dify.ai',
email: 'me@example.com',
ctx: {
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
@ -28,7 +27,7 @@ describe('runSetMember', () => {
const result = await runSetMember(
{ memberId: 'acct-2', role: 'admin' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -47,7 +46,7 @@ describe('runSetMember', () => {
runSetMember(
{ memberId: 'acct-2', role: 'owner' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -63,7 +62,7 @@ describe('runSetMember', () => {
runSetMember(
{ memberId: '', role: 'admin' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
@ -77,7 +76,7 @@ describe('runSetMember', () => {
await runSetMember(
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
{
active: active(),
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,

View File

@ -1,5 +1,5 @@
import type { KyInstance } from 'ky'
import type { ActiveContext } from '../../../auth/hosts.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
@ -18,7 +18,7 @@ export type SetMemberOptions = {
}
export type SetMemberDeps = {
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
@ -59,7 +59,7 @@ export async function runSetMember(
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
bundle: deps.bundle,
})
await runWithSpinner(

View File

@ -26,8 +26,6 @@ import HelpExternal from './help/external/index.js'
import ResumeApp from './resume/app/index.js'
import RunApp from './run/app/index.js'
import SetMember from './set/member/index.js'
import UseAccount from './use/account/index.js'
import UseHost from './use/host/index.js'
import UseWorkspace from './use/workspace/index.js'
import Version from './version/index.js'
@ -106,8 +104,6 @@ export const commandTree: CommandTree = {
},
use: {
subcommands: {
account: { command: UseAccount, subcommands: {} },
host: { command: UseHost, subcommands: {} },
workspace: { command: UseWorkspace, subcommands: {} },
},
},

View File

@ -1,22 +0,0 @@
import { Flags } from '../../../framework/flags.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runUseAccount } from './use-account.js'
export default class UseAccount extends DifyCommand {
static override description = 'Switch the active account on the current host'
static override examples = [
'<%= config.bin %> use account',
'<%= config.bin %> use account --email bob@corp.com',
]
static override flags = {
email: Flags.string({ description: 'account email to switch to', default: '' }),
}
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(UseAccount, argv)
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
}
}

View File

@ -1,63 +0,0 @@
import type { Key, Store } from '../../../store/store.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runUseAccount } from './use-account.js'
function memStore(seed: Record<string, string>): Store {
const m = new Map<string, unknown>(Object.entries(seed))
return {
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
unset<T>(k: Key<T>): void { m.delete(k.key) },
}
}
describe('runUseAccount', () => {
let dir: string
let prev: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
})
afterEach(async () => {
if (prev === undefined)
delete process.env[ENV_CONFIG_DIR]
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
it('switches current_account when email valid + token present', async () => {
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
})
it('errors when the account has no stored token', async () => {
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
.rejects
.toThrow(/log in|no credential/i)
})
it('errors when the email is unknown on the current host', async () => {
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
.rejects
.toThrow(/unknown account|no account/i)
})
it('errors in non-TTY when email omitted', async () => {
const io = bufferStreams()
;(io as { isErrTTY: boolean }).isErrTTY = false
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
})
})

View File

@ -1,76 +0,0 @@
import type { HostEntry } from '../../../auth/hosts.js'
import type { Store } from '../../../store/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { selectFromList } from '../../../sys/io/select.js'
export type UseAccountOptions = {
readonly io: IOStreams
readonly email: string | undefined
/** Optional override for tests; production resolves via `getTokenStore`. */
readonly store?: Store
}
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const reg = Registry.load()
if (reg.current_host === undefined)
throw notLoggedInError(USE_HOST_HINT)
const host = reg.current_host
const entry = reg.hosts[host]
if (entry === undefined)
throw notLoggedInError(USE_HOST_HINT)
const emails = Object.keys(entry.accounts)
const target = opts.email ?? await pickAccount(opts, entry, host)
if (!emails.includes(target)) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
})
}
const store = opts.store ?? getTokenStore().store
if (store.get(tokenKey(host, target)) === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: `no credential stored for ${target} on ${host}`,
hint: `run 'difyctl auth login --host ${host}'`,
})
}
reg.setAccount(target)
reg.save()
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
}
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
const emails = Object.keys(entry.accounts)
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
})
}
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
email,
name: ctx.account.name,
sso: ctx.external_subject !== undefined,
active: entry.current_account === email,
}))
const picked = await selectFromList<AccountChoice>({
io: opts.io,
items: choices,
header: `Select an account on ${host}`,
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
})
return picked.email
}

View File

@ -1,22 +0,0 @@
import { Flags } from '../../../framework/flags.js'
import { realStreams } from '../../../sys/io/streams'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runUseHost } from './use-host.js'
export default class UseHost extends DifyCommand {
static override description = 'Switch the active Dify host'
static override examples = [
'<%= config.bin %> use host',
'<%= config.bin %> use host --domain cloud.dify.ai',
]
static override flags = {
domain: Flags.string({ description: 'domain to switch to', default: '' }),
}
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(UseHost, argv)
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
}
}

View File

@ -1,50 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runUseHost } from './use-host.js'
describe('runUseHost', () => {
let dir: string
let prev: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
})
afterEach(async () => {
if (prev === undefined)
delete process.env[ENV_CONFIG_DIR]
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
it('switches current_host when host is valid', async () => {
await runUseHost({ io: bufferStreams(), host: 'h2' })
expect(Registry.load().current_host).toBe('h2')
})
it('errors when host is unknown, listing valid hosts', async () => {
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
})
it('errors in non-TTY when host omitted', async () => {
const io = bufferStreams()
;(io as { isErrTTY: boolean }).isErrTTY = false
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
})
it('errors when no hosts exist', async () => {
Registry.empty('file').save()
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
})
})

View File

@ -1,54 +0,0 @@
import type { IOStreams } from '../../../sys/io/streams'
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { selectFromList } from '../../../sys/io/select.js'
export type UseHostOptions = {
readonly io: IOStreams
readonly host: string | undefined
}
type HostChoice = { host: string, accounts: number, active: boolean }
export async function runUseHost(opts: UseHostOptions): Promise<void> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const reg = Registry.load()
const hosts = Object.keys(reg.hosts)
if (hosts.length === 0)
throw notLoggedInError()
const target = opts.host ?? await pickHost(opts, reg, hosts)
if (!hosts.includes(target)) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
})
}
reg.setHost(target)
reg.save()
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
}
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
})
}
const choices: HostChoice[] = hosts.map(h => ({
host: h,
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
active: reg.current_host === h,
}))
const picked = await selectFromList<HostChoice>({
io: opts.io,
items: choices,
header: 'Select a host',
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
})
return picked.host
}

View File

@ -22,8 +22,7 @@ export default class UseWorkspace extends DifyCommand {
const { args, flags } = this.parse(UseWorkspace, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
await runUseWorkspace({ workspaceId: args.workspaceId }, {
reg: ctx.reg,
active: ctx.active,
bundle: ctx.bundle,
http: ctx.http,
io: ctx.io,
})

View File

@ -3,36 +3,28 @@ 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 { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
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: {
@ -76,16 +68,14 @@ describe('runUseWorkspace', () => {
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()
saveHosts(b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -94,65 +84,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 = loadHosts()
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()
saveHosts(b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ 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 = loadHosts()
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()
saveHosts(b)
const before = loadHosts()
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -162,8 +127,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -172,18 +136,16 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = Registry.load()
const after = loadHosts()
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()
saveHosts(b)
const before = loadHosts()
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -193,8 +155,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
reg,
active,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -202,15 +163,14 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = Registry.load()
const after = loadHosts()
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()
saveHosts(b)
const client = fakeClient({
switch: () => Promise.resolve({
@ -232,8 +192,7 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
reg,
active,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,

View File

@ -1,7 +1,8 @@
import type { KyInstance } from 'ky'
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../sys/io/streams.js'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { saveHosts } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
@ -12,8 +13,7 @@ export type UseWorkspaceOptions = {
}
export type UseWorkspaceDeps = {
readonly reg: Registry
readonly active: ActiveContext
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
@ -31,12 +31,12 @@ export type UseWorkspaceDeps = {
* stays in sync. Failure here also aborts; the server-side current has
* already moved, but the local file is left untouched. A follow-up
* `difyctl get workspace` will reconcile.
* 3. Persist `workspace` + `available_workspaces` atomically via `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 +60,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()
saveHosts(next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
return deps.reg
return next
}

View File

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

View File

@ -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 18 codes (parity with internal/api/errors)', () => {
expect(ALL_ERROR_CODES).toHaveLength(18)
})
it('has the expected ExitCode buckets', () => {

View File

@ -17,7 +17,6 @@ export const ErrorCode = {
Server4xxOther: 'server_4xx_other',
ClientError: 'client_error',
Unknown: 'unknown',
IllegalArgumentError: 'illegal_argument',
} as const
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]
@ -51,7 +50,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
server_4xx_other: ExitCode.Generic,
client_error: ExitCode.Generic,
unknown: ExitCode.Generic,
illegal_argument: ExitCode.Usage,
}
export function exitFor(code: string): ExitCodeValue {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,95 +0,0 @@
import { PassThrough } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { selectFromList } from './select'
import { bufferStreams } from './streams'
type Row = { id: string, label: string }
const rows: Row[] = [
{ id: '1', label: 'alpha' },
{ id: '2', label: 'beta' },
{ id: '3', label: 'gamma' },
]
const SHOW_CURSOR = '\x1B[?25h'
type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown }
function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn {
const stream = new PassThrough() as unknown as FakeTTYIn
stream.isTTY = true
stream.isRaw = false
stream.setRawMode = (mode: boolean): unknown => {
if (opts.failRawMode === true && mode)
throw new Error('raw mode unavailable')
stream.isRaw = mode
return stream
}
return stream
}
function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> {
const io = bufferStreams()
;(io as { in: NodeJS.ReadableStream }).in = input
;(io as { isErrTTY: boolean }).isErrTTY = true
return io
}
describe('selectFromList (non-TTY numbered fallback)', () => {
it('returns the item matching the typed number', async () => {
const io = bufferStreams('2\n')
;(io as { isErrTTY: boolean }).isErrTTY = false
const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label })
expect(picked.id).toBe('2')
expect(io.errBuf()).toContain('1) alpha')
expect(io.errBuf()).toContain('Pick one')
})
it('rejects an out-of-range selection', async () => {
const io = bufferStreams('9\n')
;(io as { isErrTTY: boolean }).isErrTTY = false
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
.rejects
.toThrow(/invalid selection/i)
})
it('throws when the list is empty', async () => {
const io = bufferStreams('1\n')
;(io as { isErrTTY: boolean }).isErrTTY = false
await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label }))
.rejects
.toThrow(/nothing to select/i)
})
})
describe('selectFromList (interactive TTY picker)', () => {
it('moves with arrow keys and resolves on enter, restoring raw mode', async () => {
const input = ttyInput()
const io = ttyStreams(input)
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
input.write('\x1B[B')
input.write('\r')
const picked = await pick
expect(picked.id).toBe('2')
expect(input.isRaw).toBe(false)
expect(io.errBuf()).toContain(SHOW_CURSOR)
})
it('cancels on escape', async () => {
const input = ttyInput()
const io = ttyStreams(input)
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
input.write('\x1B')
await expect(pick).rejects.toThrow(/cancelled/i)
expect(input.isRaw).toBe(false)
})
it('rejects and restores the terminal when raw-mode setup fails', async () => {
const input = ttyInput({ failRawMode: true })
const io = ttyStreams(input)
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
.rejects
.toThrow(/raw mode unavailable/i)
expect(input.isRaw).toBe(false)
expect(io.errBuf()).toContain(SHOW_CURSOR)
})
})

View File

@ -1,153 +0,0 @@
import type { Key } from 'node:readline'
import type { IOStreams } from './streams'
import * as readline from 'node:readline'
import { BaseError } from '../../errors/base.js'
import { ErrorCode } from '../../errors/codes.js'
import { colorEnabled, colorScheme } from './color.js'
export type SelectOptions<T> = {
readonly io: IOStreams
readonly items: readonly T[]
readonly header: string
/** Single rich line shown per option. */
readonly render: (item: T) => string
/** Optional second line shown only for the focused option in the TTY picker. */
readonly describe?: (item: T) => string
}
const HIDE_CURSOR = '\x1B[?25l'
const SHOW_CURSOR = '\x1B[?25h'
const CLEAR_DOWN = '\x1B[0J'
const cursorUp = (n: number): string => `\x1B[${n}A`
export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> {
if (opts.items.length === 0)
throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' })
return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts)
}
/**
* Arrow-key picker built on Node's readline keypress events — no third-party
* prompt library, so it bundles cleanly into the compiled binary. Renders to
* the err stream, redrawing in place on each keystroke and erasing itself on
* exit so the caller's own output starts on a clean row.
*/
async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> {
const input = opts.io.in as NodeJS.ReadStream
const out = opts.io.err
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const count = opts.items.length
return new Promise<T>((resolve, reject) => {
let active = 0
let rendered = 0
const frame = (): readonly string[] => {
const lines = [opts.header]
opts.items.forEach((item, i) => {
const focused = i === active
const pointer = focused ? cs.cyan('') : ' '
const label = focused ? cs.bold(opts.render(item)) : opts.render(item)
lines.push(`${pointer} ${label}`)
})
const desc = opts.describe?.(opts.items[active] as T)
if (desc !== undefined && desc !== '')
lines.push(cs.dim(` ${desc}`))
return lines
}
const render = (): void => {
if (rendered > 0)
out.write(cursorUp(rendered))
const lines = frame()
out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`)
rendered = lines.length
}
const wasRaw = input.isTTY ? input.isRaw : false
const cleanup = (): void => {
input.off('keypress', onKey)
if (input.isTTY)
input.setRawMode(wasRaw)
input.pause()
if (rendered > 0)
out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`)
out.write(SHOW_CURSOR)
}
function onKey(_str: string | undefined, key: Key): void {
if (key.ctrl && key.name === 'c') {
cleanup()
reject(cancelled())
return
}
switch (key.name) {
case 'up':
case 'k':
active = (active - 1 + count) % count
render()
break
case 'down':
case 'j':
active = (active + 1) % count
render()
break
case 'return':
case 'enter': {
const chosen = opts.items[active]
cleanup()
if (chosen === undefined)
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }))
else
resolve(chosen)
break
}
case 'escape':
cleanup()
reject(cancelled())
break
default:
break
}
}
try {
readline.emitKeypressEvents(input)
if (input.isTTY)
input.setRawMode(true)
out.write(HIDE_CURSOR)
input.on('keypress', onKey)
input.resume()
render()
}
catch (err) {
cleanup()
reject(err)
}
})
}
function cancelled(): BaseError {
return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' })
}
async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> {
opts.io.err.write(`${opts.header}\n`)
opts.items.forEach((item, idx) => {
opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`)
})
opts.io.err.write('Enter number: ')
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
try {
const line: string = await new Promise(resolve => rl.once('line', resolve))
const n = Number(line.trim())
const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined
if (chosen === undefined)
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` })
return chosen
}
finally {
rl.close()
}
}

View File

@ -1,30 +1,30 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { ActiveContext } from '../auth/hosts.js'
import type { HostsBundle } from '../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { Registry } from '../auth/hosts.js'
import { saveHosts } from '../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { arch } from '../sys/index.js'
import { runVersionProbe } from './probe.js'
function active(overrides: Partial<ActiveContext> = {}): ActiveContext {
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
return {
host: 'cloud.dify.ai',
email: 'test@dify.ai',
ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } },
current_host: 'cloud.dify.ai',
scheme: 'https',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
...overrides,
}
} as HostsBundle
}
describe('runVersionProbe', () => {
it('returns skipped server + unknown compat when skipServer=true', async () => {
const report = await runVersionProbe({
skipServer: true,
loadActive: async () => active(),
loadBundle: async () => bundle(),
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
@ -38,7 +38,7 @@ describe('runVersionProbe', () => {
let observed: string | undefined
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }),
probe: async (endpoint) => {
observed = endpoint
return { version: '1.6.4', edition: 'CLOUD' }
@ -49,10 +49,10 @@ describe('runVersionProbe', () => {
expect(report.compat.status).toBe('compatible')
})
it('returns no-host + unknown compat when active context is missing', async () => {
it('returns no-host + unknown compat when bundle is missing', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => undefined,
loadBundle: async () => undefined,
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
@ -61,10 +61,10 @@ describe('runVersionProbe', () => {
expect(report.compat.detail).toContain('no host')
})
it('returns no-host when active context has empty host', async () => {
it('returns no-host when bundle has empty current_host', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active({ host: '' }),
loadBundle: async () => bundle({ current_host: '' }),
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
@ -72,10 +72,10 @@ describe('runVersionProbe', () => {
expect(report.compat.status).toBe('unknown')
})
it('distinguishes loadActive disk failure from no-host configured in the detail', async () => {
it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => {
const errReport = await runVersionProbe({
skipServer: false,
loadActive: async () => { throw new Error('disk-explode') },
loadBundle: async () => { throw new Error('disk-explode') },
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
expect(errReport.server.reachable).toBe(false)
@ -84,7 +84,7 @@ describe('runVersionProbe', () => {
const noHostReport = await runVersionProbe({
skipServer: false,
loadActive: async () => undefined,
loadBundle: async () => undefined,
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
expect(noHostReport.compat.detail).toContain('no host')
@ -94,7 +94,7 @@ describe('runVersionProbe', () => {
it('returns compatible report when server is reachable and in range', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
loadBundle: async () => bundle(),
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})
@ -108,7 +108,7 @@ describe('runVersionProbe', () => {
it('returns unsupported when server version is out of range', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
loadBundle: async () => bundle(),
probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }),
})
@ -119,7 +119,7 @@ describe('runVersionProbe', () => {
it('returns unknown when server returns an empty version string', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
loadBundle: async () => bundle(),
probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }),
})
@ -130,7 +130,7 @@ describe('runVersionProbe', () => {
it('treats probe rejection as unreachable + unknown compat', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active(),
loadBundle: async () => bundle(),
probe: async () => { throw new Error('timeout') },
})
@ -141,10 +141,10 @@ describe('runVersionProbe', () => {
expect(report.compat.detail).toContain('unreachable')
})
it('builds endpoint using active scheme when host has no scheme', async () => {
it('builds endpoint using bundle scheme when host has no scheme', async () => {
const report = await runVersionProbe({
skipServer: false,
loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }),
loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }),
probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }),
})
@ -161,12 +161,12 @@ describe('runVersionProbe', () => {
const prevConfig = process.env[ENV_CONFIG_DIR]
try {
process.env[ENV_CONFIG_DIR] = configDir
const reg = Registry.empty('file')
reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } })
reg.setHost(url.host)
reg.setAccount('test@dify.ai')
reg.setScheme(url.host, url.protocol.replace(':', ''))
reg.save()
saveHosts({
current_host: url.host,
scheme: url.protocol.replace(':', ''),
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
})
process.env[ENV_CONFIG_DIR] = configDir
const report = await runVersionProbe({ skipServer: false })
@ -190,7 +190,7 @@ describe('runVersionProbe', () => {
it('always includes client metadata in the report', async () => {
const report = await runVersionProbe({
skipServer: true,
loadActive: async () => undefined,
loadBundle: async () => undefined,
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
})

View File

@ -1,9 +1,9 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { ActiveContext } from '../auth/hosts.js'
import type { HostsBundle } from '../auth/hosts.js'
import type { CompatVerdict } from './compat.js'
import type { Channel } from './info.js'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
import { Registry } from '../auth/hosts.js'
import { loadHosts } from '../auth/hosts.js'
import { createClient } from '../http/client.js'
import { arch, platform } from '../sys/index.js'
import { hostWithScheme } from '../util/host.js'
@ -43,13 +43,11 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse>
export type RunVersionProbeOptions = {
readonly skipServer: boolean
readonly loadActive?: () => Promise<ActiveContext | undefined>
readonly loadBundle?: () => Promise<HostsBundle | undefined>
readonly probe?: MetaProbe
}
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
return Registry.load().resolveActive()
}
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
const defaultProbe: MetaProbe = async (endpoint) => {
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
@ -91,19 +89,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
}
const loadActive = opts.loadActive ?? defaultLoadActive
const loadBundle = opts.loadBundle ?? defaultLoadBundle
const probe = opts.probe ?? defaultProbe
let active: ActiveContext | undefined
let bundle: HostsBundle | undefined
let loadFailed = false
try {
active = await loadActive()
bundle = await loadBundle()
}
catch {
loadFailed = true
}
if (active === undefined || active.host === '') {
if (bundle === undefined || bundle.current_host === '') {
const detail = loadFailed ? 'hosts file unreadable' : 'no host configured'
return {
client,
@ -112,7 +110,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
}
}
const endpoint = hostWithScheme(active.host, active.scheme)
const endpoint = hostWithScheme(bundle.current_host, bundle.scheme)
let serverInfo: ServerVersionResponse | undefined
try {

View File

@ -1,11 +1,11 @@
import type { ActiveContext } from '../auth/hosts.js'
import type { HostsBundle } from '../auth/hosts.js'
import { BaseError } from '../errors/base.js'
import { ErrorCode } from '../errors/codes.js'
export type WorkspaceResolveInputs = {
readonly flag?: string
readonly env?: string
readonly active?: ActiveContext
readonly bundle?: HostsBundle
}
export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
return inputs.flag
if (truthy(inputs.env))
return inputs.env
const ctx = inputs.active?.ctx
if (ctx !== undefined) {
if (truthy(ctx.workspace?.id))
return ctx.workspace.id
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
&& truthy(ctx.available_workspaces[0]?.id)) {
return ctx.available_workspaces[0].id
const b = inputs.bundle
if (b !== undefined) {
if (truthy(b.workspace?.id))
return b.workspace.id
if (b.available_workspaces !== undefined && b.available_workspaces.length > 0
&& truthy(b.available_workspaces[0]?.id)) {
return b.available_workspaces[0].id
}
}
throw new BaseError({

View File

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

View File

@ -356,22 +356,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
subject_type: 'external_sso',
subject_email: 'sso@dify.ai',
subject_issuer: 'https://issuer.example',
account: null,
workspaces: [],
default_workspace_id: null,
token_id: 'tok-sso-1',
})
}
if (scenario === 'no-email') {
return c.json({
token: 'dfoa_test',
subject_type: 'account',
account: { id: ACCOUNT.id, email: '', name: '' },
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
default_workspace_id: 'ws-1',
token_id: 'tok-1',
})
}
return c.json({
token: 'dfoa_test',
subject_type: 'account',

View File

@ -24,7 +24,6 @@ import {
zGetAppFeedbacksResponse,
zGetAppsAnnotationReplyByActionStatusByJobIdPath,
zGetAppsAnnotationReplyByActionStatusByJobIdResponse,
zGetAppsAnnotationsQuery,
zGetAppsAnnotationsResponse,
zGetConversationsByCIdVariablesPath,
zGetConversationsByCIdVariablesQuery,
@ -380,7 +379,6 @@ export const get4 = oc
summary: 'List annotations for the application',
tags: ['service_api'],
})
.input(z.object({ query: zGetAppsAnnotationsQuery.optional() }))
.output(zGetAppsAnnotationsResponse)
/**

View File

@ -25,12 +25,6 @@ export type AnnotationList = {
total: number
}
export type AnnotationListQuery = {
keyword?: string
limit?: number
page?: number
}
export type AnnotationReplyActionPayload = {
embedding_model_name: string
embedding_provider_name: string
@ -975,11 +969,7 @@ export type GetAppsAnnotationReplyByActionStatusByJobIdResponse
export type GetAppsAnnotationsData = {
body?: never
path?: never
query?: {
keyword?: string
limit?: number
page?: number
}
query?: never
url: '/apps/annotations'
}

View File

@ -32,15 +32,6 @@ export const zAnnotationList = z.object({
total: z.int(),
})
/**
* AnnotationListQuery
*/
export const zAnnotationListQuery = z.object({
keyword: z.string().optional().default(''),
limit: z.int().gte(1).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* AnnotationReplyActionPayload
*/
@ -1225,12 +1216,6 @@ export const zGetAppsAnnotationReplyByActionStatusByJobIdResponse = z.record(
z.unknown(),
)
export const zGetAppsAnnotationsQuery = z.object({
keyword: z.string().optional().default(''),
limit: z.int().gte(1).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Annotations retrieved successfully
*/

View File

@ -45,7 +45,6 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))

View File

@ -42,7 +42,6 @@ vi.mock('@/next/navigation', () => ({
push: mockRouterPush,
replace: mockRouterReplace,
}),
usePathname: () => '/apps',
useSearchParams: () => new URLSearchParams(),
}))

View File

@ -199,9 +199,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
default: (_props: { ref?: React.Ref<unknown> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
},
}))
vi.mock('../empty', () => ({

View File

@ -6,10 +6,9 @@ import DevicePage from '../page'
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockDeviceLookup = vi.fn()
let mockSearchParams: Record<string, string | null> = {}
vi.mock('@/next/navigation', () => ({
useSearchParams: () => ({ get: (key: string) => mockSearchParams[key] ?? null }),
useSearchParams: () => ({ get: () => null }),
useRouter: () => ({ push: mockPush, replace: mockReplace }),
usePathname: () => '/device',
}))
@ -54,12 +53,6 @@ let MockDeviceFlowError: MockDeviceFlowErrorCtor
beforeEach(async () => {
vi.clearAllMocks()
mockSearchParams = {}
// router.replace(pathname) in the real app drops the query string; mirror
// that so useSearchParams reflects the cleared URL on the next render.
mockReplace.mockImplementation(() => {
mockSearchParams = {}
})
mockUseQuery.mockReturnValue({ data: undefined, isError: false } as ReturnType<typeof useQuery>)
const mod = await import('@/service/device-flow') as { DeviceFlowError: MockDeviceFlowErrorCtor }
MockDeviceFlowError = mod.DeviceFlowError
@ -117,41 +110,3 @@ describe('error_lookup_failed terminal state', () => {
expect(screen.queryByText('Could not verify the code')).not.toBeInTheDocument()
})
})
describe('sso_error inline banner on the code-entry page', () => {
const SSO_BANNER_COPY = /identity is linked to a Dify account/i
it('shows the error banner with friendly copy when sso_error is present', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
expect(await screen.findByText(SSO_BANNER_COPY)).toBeInTheDocument()
})
it('keeps the code-entry screen visible (error on main page, not a separate view)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Continue/i })).toBeInTheDocument()
})
it('does not surface the raw backend error code', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(screen.queryByText('email_belongs_to_dify_account')).not.toBeInTheDocument()
})
it('does not scrub the param on mount (regression: error was wiped by router.replace)', async () => {
mockSearchParams = { sso_error: 'email_belongs_to_dify_account' }
render(<DevicePage />)
await screen.findByText(SSO_BANNER_COPY)
expect(mockReplace).not.toHaveBeenCalled()
})
it('shows no banner when sso_error is absent', () => {
render(<DevicePage />)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.queryByText(SSO_BANNER_COPY)).not.toBeInTheDocument()
})
})

View File

@ -14,7 +14,7 @@ import AuthorizeAccount from './components/authorize-account'
import AuthorizeSSO from './components/authorize-sso'
import Chooser from './components/chooser'
import CodeInput from './components/code-input'
import { classifyLookupError, ssoErrorCopy } from './utils/error-copy'
import { classifyLookupError } from './utils/error-copy'
import { isValidUserCode } from './utils/user-code'
type View
@ -33,7 +33,6 @@ export default function DevicePage() {
const pathname = usePathname()
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
const ssoVerified = searchParams.get('sso_verified') === '1'
const ssoError = searchParams.get('sso_error') || ''
const [typed, setTyped] = useState('')
const [view, setView] = useState<View>({ kind: 'code_entry' })
@ -126,12 +125,6 @@ export default function DevicePage() {
<>
{view.kind === 'code_entry' && (
<div className="flex flex-col gap-5">
{ssoError && (
<div className="flex items-start gap-2 rounded-lg bg-state-destructive-hover p-3">
<span className="mt-0.5 i-ri-close-circle-line h-4 w-4 shrink-0 text-util-colors-red-red-600" />
<p className="text-sm text-text-destructive">{ssoErrorCopy(ssoError)}</p>
</div>
)}
<div>
<h1 className="text-2xl font-semibold text-text-primary">Authorize Dify CLI</h1>
<p className="mt-2 text-sm text-text-secondary">

View File

@ -30,18 +30,6 @@ export function approveErrorCopy(err: unknown): string {
return DEFAULT_MESSAGE
}
// SSO-branch failures arrive as a `sso_error` query param set by the backend
// (oauth_device_sso sso-complete) when it redirects back to /device.
const SSO_ERROR_COPY: Record<string, string> = {
email_belongs_to_dify_account: 'This identity is linked to a Dify account. Use “Sign in with Dify account” instead.',
}
const DEFAULT_SSO_ERROR_MESSAGE = 'Single sign-on could not be completed. Try again.'
export function ssoErrorCopy(code: string): string {
return SSO_ERROR_COPY[code] ?? DEFAULT_SSO_ERROR_MESSAGE
}
export type LookupOutcome = 'expired' | 'rate_limited' | 'failed'
export function classifyLookupError(err: unknown): LookupOutcome {