Compare commits

..

7 Commits

Author SHA1 Message Date
e4c98692f7 refactor(cli): make Registry.load() non-nullable and use requireActive()
Registry.load() now always returns a Registry (empty when no config file
exists) instead of Registry | undefined. The 'no config file' and 'no
active account' states were handled identically by every caller, so they
collapse into one signal: resolveActive() returning undefined.

Callers drop the reg?. guards and the '?? Registry.empty(mode)' fallback.
whoami and logout, which hand-reimplemented requireActive(), now call it
directly.
2026-05-29 04:43:21 -07:00
2fc6febb3f Merge remote-tracking branch 'origin/main' into fix/cli-token-storage-unify
# Conflicts:
#	cli/src/commands/auth/login/login.test.ts
#	cli/src/commands/auth/login/login.ts
2026-05-29 04:27:08 -07:00
0bbe60beb4 refactor(cli): unify token storage behind Store + add host/account switching
Route all credential reads/writes through the Store interface so keychain
and file backends share one code path. Add `use host` / `use account`
commands for multi-account switching, with a zero-dependency arrow-key
dropdown (replaces @inquirer/prompts, which broke under bun --compile).

- login: blue dify spinner during device-flow poll; fail fast with a flag
  hint when host is omitted in a non-TTY (agent) context
- select: custom keypress picker, restores raw mode / cursor on any exit
  including synchronous setup failure
- hosts: remove() no longer clears the active host when a non-active
  account is removed

Tests: 836 passing.
2026-05-29 04:19:48 -07:00
ae538ced47 chore: using single SSH_SCRIPT for saas dev (#36827) 2026-05-29 10:07:15 +00:00
487249728b fix: remove unnecessary # type: ignore comments (#24494) (#36825) 2026-05-29 09:41:32 +00:00
372a2e3e9c refactor: convert isinstance chains to match/case (part 7) (#35902) (#36826) 2026-05-29 09:40:33 +00:00
4939a9c33d refactor: add ts common style check for web and cli (#36823) 2026-05-29 09:26:32 +00:00
232 changed files with 2185 additions and 13333 deletions

View File

@ -25,4 +25,4 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
${{ vars.SSH_SCRIPT_SAAS_DEV || secrets.SSH_SCRIPT_SAAS_DEV }}

View File

@ -95,6 +95,51 @@ 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
@ -105,28 +150,14 @@ 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: Web style check
- name: Style check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: .
run: vp run lint:ci
- name: Web tsslint
- name: Type check
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

@ -562,15 +562,16 @@ class WorkflowResponseConverter:
outputs, outputs_truncated = self._truncate_mapping(encoded_outputs)
metadata = self._merge_metadata(event.execution_metadata, snapshot)
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
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
return NodeFinishStreamResponse(
task_id=task_id,

View File

@ -91,26 +91,28 @@ class AppGeneratorTTSPublisher:
)
future_queue.put(futures_result)
break
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
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
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,36 +54,39 @@ class Blob(BaseModel):
def as_string(self) -> str:
"""Read data as a string."""
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}")
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}")
def as_bytes(self) -> bytes:
"""Read data as bytes."""
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}")
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}")
@contextlib.contextmanager
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
"""Read data as a byte stream."""
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}")
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}")
@classmethod
def from_path(

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: # type: ignore
# for notion_info in notion_info_list:
# 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) # type: ignore
# count = len(website_info.urls)
# 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 # type: ignore
# dataset.data_source_type = knowledge_config.data_source.info_list.data_source_type
# 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": # type: ignore
# upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids # type: ignore
# 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
# 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 # type: ignore
# document.dataset_process_rule_id = dataset_process_rule.id
# 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, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# 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": # type: ignore
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
# elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
# notion_info_list = knowledge_config.data_source.info_list.notion_info_list
# 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, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# 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": # type: ignore
# website_info = knowledge_config.data_source.info_list.website_info_list # type: ignore
# elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
# website_info = knowledge_config.data_source.info_list.website_info_list
# 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, # type: ignore
# knowledge_config.data_source.info_list.data_source_type, # type: ignore
# dataset_process_rule.id,
# knowledge_config.data_source.info_list.data_source_type,
# knowledge_config.doc_form,
# knowledge_config.doc_language,
# data_source_info,

View File

@ -1,131 +1,202 @@
import type { Key, Store } from '../store/store.js'
import type { AccountContext } from './hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ENV_CONFIG_DIR } from '../store/dir.js'
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts.js'
describe('HostsBundleSchema', () => {
it('parses a minimal logged-out bundle', () => {
const parsed = HostsBundleSchema.parse({})
expect(parsed.current_host).toBe('')
expect(parsed.token_storage).toBe('file')
describe('RegistrySchema', () => {
it('parses an empty registry with defaults', () => {
const reg = RegistrySchema.parse({})
expect(reg.token_storage).toBe('file')
expect(reg.current_host).toBeUndefined()
expect(reg.hosts).toEqual({})
})
it('parses a logged-in keychain bundle', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
it('parses a populated multi-host registry', () => {
const reg = RegistrySchema.parse({
token_storage: 'keychain',
token_id: 'tok_xyz',
current_host: 'cloud.dify.ai',
hosts: {
'cloud.dify.ai': {
current_account: 'bob@corp.com',
accounts: {
'bob@corp.com': {
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
token_id: 'tok_1',
},
},
},
},
})
expect(parsed.token_storage).toBe('keychain')
expect(parsed.tokens).toBeUndefined()
expect(reg.current_host).toBe('cloud.dify.ai')
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
})
it('parses a logged-in file bundle with bearer', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_xxx' },
})
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
it('defaults a host entry accounts map to {}', () => {
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
expect(reg.hosts.h?.accounts).toEqual({})
})
it('rejects unknown token_storage values', () => {
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
})
it('keeps available_workspaces when provided', () => {
const parsed = HostsBundleSchema.parse({
available_workspaces: [
{ id: 'a', name: 'A', role: 'owner' },
{ id: 'b', name: 'B', role: 'member' },
],
it('AccountContextSchema keeps optional external_subject', () => {
const ctx = AccountContextSchema.parse({
account: { id: '', email: 'sso@x.io', name: '' },
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
})
expect(parsed.available_workspaces).toHaveLength(2)
})
it('drops unknown top-level fields on parse', () => {
const parsed = HostsBundleSchema.parse({
current_host: 'cloud.dify.ai',
future_field: 42,
token_storage: 'file',
})
expect(parsed.current_host).toBe('cloud.dify.ai')
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
expect(ctx.external_subject?.issuer).toBe('https://issuer')
})
})
describe('loadHosts/saveHosts', () => {
let dir: string
let prevConfigDir: string | undefined
describe('notLoggedInError', () => {
it('carries the default hint', () => {
expect(notLoggedInError().toString()).toMatch(/auth login/)
})
it('accepts a custom hint', () => {
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
})
})
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
describe('Registry (pure)', () => {
const baseReg = (): Registry => Registry.empty('file')
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
it('upsert creates host + account; remove drops them', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.upsert('h1', 'b@x', ctx('b@x'))
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
reg.remove('h1', 'a@x')
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
reg.remove('h1', 'b@x')
expect(reg.hosts.h1).toBeUndefined()
})
it('setHost / setAccount set pointers', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setHost('h1')
reg.setAccount('a@x')
expect(reg.current_host).toBe('h1')
expect(reg.hosts.h1?.current_account).toBe('a@x')
})
it('resolveActive returns the active context with scheme', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setScheme('h1', 'http')
reg.setHost('h1')
reg.setAccount('a@x')
const active = reg.resolveActive()
expect(active?.host).toBe('h1')
expect(active?.email).toBe('a@x')
expect(active?.scheme).toBe('http')
expect(active?.ctx.account.email).toBe('a@x')
})
it('resolveActive returns undefined for each missing pointer', () => {
const reg = baseReg()
expect(reg.resolveActive()).toBeUndefined()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setHost('missing')
expect(reg.resolveActive()).toBeUndefined()
reg.setHost('h1')
expect(reg.resolveActive()).toBeUndefined()
reg.setAccount('missing@x')
expect(reg.resolveActive()).toBeUndefined()
})
it('remove unsets pointers when removing the active account', () => {
const reg = baseReg()
reg.upsert('h1', 'a@x', ctx('a@x'))
reg.setHost('h1')
reg.setAccount('a@x')
reg.remove('h1', 'a@x')
expect(reg.current_host).toBeUndefined()
expect(reg.resolveActive()).toBeUndefined()
})
})
describe('Registry.load / Registry.save', () => {
let dir: string
let prev: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
if (prev === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
it('returns undefined when nothing was saved', () => {
expect(loadHosts()).toBeUndefined()
it('returns an empty registry when nothing saved', () => {
const reg = Registry.load()
expect(reg.current_host).toBeUndefined()
expect(Object.keys(reg.hosts)).toHaveLength(0)
})
it('round-trips a fully-populated bundle', () => {
saveHosts({
current_host: 'cloud.dify.ai',
scheme: 'https',
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'My Space', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
token_storage: 'keychain',
token_id: 'tok_xyz',
})
const loaded = loadHosts()
it('round-trips a populated registry', () => {
const reg = Registry.empty('keychain')
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.setHost('cloud.dify.ai')
reg.setAccount('a@x')
reg.save()
const loaded = Registry.load()
expect(loaded?.current_host).toBe('cloud.dify.ai')
expect(loaded?.scheme).toBe('https')
expect(loaded?.account?.email).toBe('a@b.c')
expect(loaded?.workspace?.id).toBe('ws-1')
expect(loaded?.available_workspaces).toHaveLength(2)
expect(loaded?.token_storage).toBe('keychain')
expect(loaded?.token_id).toBe('tok_xyz')
})
it('round-trips a file-mode bundle with bearer token', () => {
saveHosts({
current_host: 'self.example.com',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
})
const loaded = loadHosts()
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
expect(loaded?.token_storage).toBe('file')
})
it('overwrites previous bundle on save', () => {
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
const loaded = loadHosts()
expect(loaded?.current_host).toBe('new.example.com')
expect(loaded?.token_storage).toBe('keychain')
})
it('rejects invalid input at save time', () => {
expect(() => saveHosts({
current_host: 'cloud.dify.ai',
token_storage: 'cloud',
} as never)).toThrow()
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
})
})
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
}
describe('Registry.forget', () => {
let dir: string
let prev: string | undefined
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prev === undefined)
delete process.env[ENV_CONFIG_DIR]
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
it('drops token + active context, keeps siblings, unsets pointers', () => {
const store = new MemStore()
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
const active = reg.resolveActive()!
reg.forget(active, store)
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
const after = Registry.load()
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
expect(after?.current_host).toBeUndefined()
})
})

View File

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

View File

@ -1,17 +1,17 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../auth/hosts.js'
import type { ActiveContext } from '../../auth/hosts.js'
import type { AppInfoCache } from '../../cache/app-info.js'
import type { Command } from '../../framework/command.js'
import type { Store } from '../../store/store.js'
import type { IOStreams } from '../../sys/io/streams'
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
import { loadHosts } from '../../auth/hosts.js'
import { notLoggedInError, Registry } from '../../auth/hosts.js'
import { loadAppInfoCache } from '../../cache/app-info.js'
import { loadNudgeStore } from '../../cache/nudge-store.js'
import { getEnv } from '../../env/registry.js'
import { BaseError } from '../../errors/base.js'
import { ErrorCode } from '../../errors/codes.js'
import { formatErrorForCli } from '../../errors/format.js'
import { createClient } from '../../http/client.js'
import { getTokenStore, tokenKey } from '../../store/manager.js'
import { realStreams } from '../../sys/io/streams'
import { hostWithScheme } from '../../util/host.js'
import { versionInfo } from '../../version/info.js'
@ -19,7 +19,9 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
import { resolveRetryAttempts } from './global-flags.js'
export type AuthedContext = {
readonly bundle: HostsBundle
readonly reg: Registry
readonly active: ActiveContext
readonly store: Store
readonly http: KyInstance
readonly host: string
readonly io: IOStreams
@ -37,28 +39,30 @@ export async function buildAuthedContext(
opts: AuthedContextOptions,
): Promise<AuthedContext> {
const io = realStreams(opts.format ?? '')
const bundle = loadHosts()
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
const err = new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
}
const reg = Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
const host = hostWithScheme(bundle.current_host, bundle.scheme)
const retryAttempts = resolveRetryAttempts({
flag: opts.retryFlag,
env: getEnv,
})
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
const { store } = getTokenStore()
const bearer = store.get(tokenKey(active.host, active.email))
if (bearer === '')
fail(cmd, opts, io)
const host = hostWithScheme(active.host, active.scheme)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
const http = createClient({ host, bearer, retryAttempts })
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
await runCompatNudge({ host, io })
return { bundle, http, host, io, cache }
return { reg, active, store, http, host, io, cache }
}
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
const err = notLoggedInError()
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed

View File

@ -1,16 +1,16 @@
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { ActiveContext } from '../../../../auth/hosts.js'
import type { Key, Store } from '../../../../store/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../../auth/hosts.js'
import { Registry } from '../../../../auth/hosts.js'
import { createClient } from '../../../../http/client.js'
import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
import { ENV_CONFIG_DIR } from '../../../../store/dir.js'
import { tokenKey } from '../../../../store/manager.js'
import { bufferStreams } from '../../../../sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
@ -30,20 +30,21 @@ class MemStore implements Store {
}
}
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
return {
current_host: host,
scheme: 'http',
token_storage: 'file',
token_id: tokenId,
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
const reg = Registry.empty('file')
reg.upsert(host, email, {
account: { id: 'acct-1', email, name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
token_id: tokenId,
})
reg.setHost(host)
reg.setAccount(email)
const active = reg.resolveActive()!
return { reg, active }
}
describe('runDevicesList', () => {
@ -58,7 +59,7 @@ describe('runDevicesList', () => {
it('table: marks current with *', async () => {
const io = bufferStreams()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
await runDevicesList({ io, tokenId: 'tok-1', http })
const out = io.outBuf()
expect(out).toContain('DEVICE')
expect(out).toContain('difyctl on laptop')
@ -71,20 +72,12 @@ describe('runDevicesList', () => {
it('json: emits PaginationEnvelope unchanged', async () => {
const io = bufferStreams()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true })
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
expect(parsed.page).toBe(1)
expect(Array.isArray(parsed.data)).toBe(true)
expect((parsed.data as unknown[]).length).toBe(3)
})
it('not-logged-in: throws NotLoggedIn', async () => {
const io = bufferStreams()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesList({ io, bundle: undefined, http }))
.rejects
.toThrow(/not logged in/)
})
})
describe('runDevicesRevoke', () => {
@ -109,12 +102,12 @@ describe('runDevicesRevoke', () => {
it('exact device_label: revokes one + leaves local creds', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
reg.save()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
expect(store.entries.size).toBe(1)
})
@ -122,30 +115,30 @@ describe('runDevicesRevoke', () => {
it('exact id: revokes one', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
it('substring: unique match revokes', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
expect(io.outBuf()).toContain('Revoked 1 session(s)')
})
it('substring: ambiguous throws', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
})
@ -153,10 +146,10 @@ describe('runDevicesRevoke', () => {
it('no match throws', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
})
@ -164,31 +157,33 @@ describe('runDevicesRevoke', () => {
it('--all: revokes everything except current', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
await runDevicesRevoke({ io, reg, active, store, http, all: true })
expect(io.outBuf()).toContain('Revoked 2 session(s)')
})
it('revoking current id clears local creds', async () => {
it('revoking current session clears token and removes context from registry', async () => {
const io = bufferStreams()
const store = new MemStore()
const b = bundleFor(mock.url, 'tok-1')
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
saveHosts(b)
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
reg.save()
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
expect(store.entries.size).toBe(0)
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
const saved = Registry.load()
expect(saved?.hosts[mock.url]).toBeUndefined()
})
it('no target + no --all: throws UsageMissingArg', async () => {
const io = bufferStreams()
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
.rejects
.toThrow(/specify a device label/)
})

View File

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

View File

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

View File

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

View File

@ -59,7 +59,7 @@ describe('runLogin', () => {
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
const reg = await runLogin({
io,
host: mock.url,
noBrowser: true,
@ -70,16 +70,17 @@ describe('runLogin', () => {
clock: noopClock,
browserOpener: noopBrowser,
})
expect(bundle.tokens?.bearer).toBe('dfoa_test')
expect(bundle.account?.email).toBe('tester@dify.ai')
expect(bundle.workspace?.id).toBe('ws-1')
expect(bundle.available_workspaces).toHaveLength(2)
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
expect(stored).toBe('dfoa_test')
const active = reg.resolveActive()
expect(active?.ctx.account.email).toBe('tester@dify.ai')
expect(active?.ctx.workspace?.id).toBe('ws-1')
expect(active?.ctx.available_workspaces).toHaveLength(2)
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsRaw).toContain('current_host:')
expect(hostsRaw).toContain('tester@dify.ai')
expect(hostsRaw).not.toContain('dfoa_test')
expect(hostsRaw).not.toContain('bearer')
expect(io.outBuf()).toContain('Logged in to')
expect(io.outBuf()).toContain('tester@dify.ai')
@ -91,7 +92,7 @@ describe('runLogin', () => {
mock.setScenario('sso')
const io = bufferStreams()
const store = new MemStore()
const bundle = await runLogin({
const reg = await runLogin({
io,
host: mock.url,
noBrowser: true,
@ -102,12 +103,11 @@ describe('runLogin', () => {
clock: noopClock,
browserOpener: noopBrowser,
})
expect(bundle.tokens?.bearer).toBe('dfoe_test')
expect(bundle.account).toBeUndefined()
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
expect(stored).toBe('dfoe_test')
const active = reg.resolveActive()
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
expect(active?.ctx.account.email).toBe('')
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
expect(io.outBuf()).toContain('external SSO')
expect(io.outBuf()).toContain('sso@dify.ai')
})
@ -148,6 +148,24 @@ describe('runLogin', () => {
})).rejects.toThrow(/expired/)
})
it('rejects login when the account has no email', async () => {
mock.setScenario('no-email')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(createClient({ host: mock.url })),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/no email/i)
expect(store.entries.size).toBe(0)
})
it('rejects http:// host without --insecure', async () => {
const io = bufferStreams()
const store = new MemStore()

View File

@ -1,5 +1,5 @@
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
import type { StorageMode, Store } from '../../../store/store.js'
import type { IOStreams } from '../../../sys/io/streams'
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
@ -7,10 +7,13 @@ import type { Clock } from './device-flow.js'
import * as os from 'node:os'
import * as readline from 'node:readline'
import { DeviceFlowApi } from '../../../api/oauth-device.js'
import { saveHosts } from '../../../auth/hosts.js'
import { Registry } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { createClient } from '../../../http/client.js'
import { getTokenStore, tokenKey } from '../../../store/manager.js'
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
import { startSpinner } from '../../../sys/io/spinner.js'
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
import { awaitAuthorization, realClock } from './device-flow.js'
@ -28,7 +31,7 @@ export type LoginOptions = {
readonly clock?: Clock
}
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
export async function runLogin(opts: LoginOptions): Promise<Registry> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const insecure = opts.insecure ?? false
@ -56,22 +59,44 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
}
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
let success: PollSuccess
try {
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
}
finally {
spinner.stop()
}
const storeBundle = opts.store ?? getTokenStore()
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
const display = bareHost(host)
const email = accountEmail(success)
const ctx = contextFromSuccess(success)
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
saveHosts(bundle)
storeBundle.store.set(tokenKey(display, email), success.token)
const reg = Registry.load()
reg.token_storage = storeBundle.mode
reg.activate(display, email, ctx)
applyScheme(reg, display, host)
reg.save()
renderLoggedIn(opts.io.out, cs, host, success)
return bundle
return reg
}
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
let raw = opts.host?.trim() ?? ''
if (raw === '')
if (raw === '') {
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: '--host is required (no TTY)',
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
})
}
raw = await promptHost(opts.io)
}
return resolveHost({ raw, insecure })
}
@ -122,50 +147,43 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
return s.workspaces?.find(w => w.id === s.default_workspace_id)
}
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
const display = bareHost(host)
let scheme: string | undefined
try {
const u = new URL(host)
if (u.protocol !== 'https:')
scheme = u.protocol.replace(':', '')
function accountEmail(s: PollSuccess): string {
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
if (email === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'account has no email; cannot store credential',
hint: 'this Dify instance returned no email for the signed-in subject',
})
}
catch { /* keep undefined */ }
return email
}
const bundle: HostsBundle = {
current_host: display,
scheme,
token_storage: mode,
function contextFromSuccess(s: PollSuccess): AccountContext {
const ctx: AccountContext = {
account: s.account
? { id: s.account.id, email: s.account.email, name: s.account.name }
: { id: '', email: '', name: '' },
token_id: s.token_id,
tokens: { bearer: s.token },
}
if (s.account) {
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (!s.account || s.account.id === '')) {
bundle.external_subject = {
email: s.subject_email,
issuer: s.subject_issuer ?? '',
}
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
bundle.workspace = def
ctx.workspace = def
if (s.workspaces !== undefined && s.workspaces.length > 0) {
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
id: w.id,
name: w.name,
role: w.role,
}))
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
}
return bundle
return ctx
}
function accountKey(b: HostsBundle): string {
if (b.account?.id !== undefined && b.account.id !== '')
return b.account.id
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
return b.external_subject.email
return 'default'
function applyScheme(reg: Registry, display: string, host: string): void {
try {
const u = new URL(host)
if (u.protocol !== 'https:')
reg.setScheme(display, u.protocol.replace(':', ''))
}
catch { /* keep scheme unset */ }
}

View File

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

View File

@ -1,145 +1,64 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { Key, Store } from '../../../store/store.js'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../auth/hosts.js'
import { createClient } from '../../../http/client.js'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { tokenKey } from '../../../store/manager.js'
import { bufferStreams } from '../../../sys/io/streams'
import { runLogout } from './logout.js'
class MemStore implements Store {
readonly entries = new Map<string, unknown>()
get<T>(key: Key<T>): T {
return (this.entries.get(key.key) as T | undefined) ?? key.default
}
set<T>(key: Key<T>, value: T): void {
this.entries.set(key.key, value)
}
unset<T>(key: Key<T>): void {
this.entries.delete(key.key)
}
}
function fixtureBundle(host: string): HostsBundle {
return {
current_host: host,
scheme: 'http',
token_storage: 'file',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
}
describe('runLogout', () => {
let mock: DifyMock
let configDir: string
let prevConfigDir: string | undefined
let dir: string
let prev: string | undefined
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
prevConfigDir = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = configDir
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
prev = process.env[ENV_CONFIG_DIR]
process.env[ENV_CONFIG_DIR] = dir
})
afterEach(async () => {
if (prevConfigDir === undefined)
if (prev === undefined)
delete process.env[ENV_CONFIG_DIR]
else
process.env[ENV_CONFIG_DIR] = prevConfigDir
await mock.stop()
await rm(configDir, { recursive: true, force: true })
else process.env[ENV_CONFIG_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
it('happy: revokes server side, clears local store + hosts.yml', async () => {
const io = bufferStreams()
function seed(store: MemStore) {
const reg = Registry.empty('file')
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
reg.setHost('h1')
reg.setAccount('a@x')
reg.save()
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
}
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
expect(io.outBuf()).toContain('Logged out of')
expect(io.errBuf()).toBe('')
seed(store)
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
const after = Registry.load()
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
expect(after?.current_host).toBeUndefined()
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
expect(raw).toContain('b@x')
})
it('not-logged-in: throws BaseError', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
})
it('hosts.yml absent: still completes locally + emits success', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
expect(io.outBuf()).toContain('Logged out of')
})
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
saveHosts(bundle)
mock.setScenario('server-5xx')
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
await runLogout({ io, bundle, http, store })
expect(store.entries.size).toBe(0)
expect(io.errBuf()).toContain('server revoke failed')
expect(io.outBuf()).toContain('Logged out of')
})
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
bundle.tokens = { bearer: 'dfp_personal_token' }
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
saveHosts(bundle)
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
await runLogout({ io, bundle, http, store })
expect(io.errBuf()).toBe('')
expect(store.entries.size).toBe(0)
})
it('preserves unrelated files in configDir', async () => {
const io = bufferStreams()
const store = new MemStore()
const bundle = fixtureBundle(mock.url)
saveHosts(bundle)
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
await runLogout({ io, bundle, http, store })
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
expect(cfg).toContain('foo: bar')
it('throws NotLoggedIn when no active context', async () => {
Registry.empty('file').save()
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
.rejects
.toThrow(/not logged in/i)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runCreateMember(
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

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

View File

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

View File

@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runDeleteMember(
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

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

View File

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

View File

@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
format,
data: await runDescribeApp(
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
),
})
}

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -12,17 +12,18 @@ import { ENV_CACHE_DIR } from '../../../store/dir.js'
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
import { runDescribeApp } from './run.js'
function bundle(): HostsBundle {
function active(): ActiveContext {
return {
current_host: 'http://localhost',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
host: 'http://localhost',
email: 't@d.ai',
ctx: {
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
}
}
@ -49,7 +50,7 @@ describe('runDescribeApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const data = await runDescribeApp(
opts,
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
}
@ -92,13 +93,13 @@ describe('runDescribeApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runDescribeApp(
{ appId: 'app-1' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
const before = cache.get(mock.url, 'app-1')
expect(before).toBeDefined()
await runDescribeApp(
{ appId: 'app-1', refresh: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
)
const after = cache.get(mock.url, 'app-1')
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
@ -112,7 +113,7 @@ describe('runDescribeApp', () => {
await expect(runDescribeApp(
{ appId: 'nope' },
{
bundle: bundle(),
active: active(),
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
},

View File

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

View File

@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
name: flags.name,
tag: flags.tag,
format,
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
}, { active: ctx.active, http: ctx.http, io: ctx.io })
return table({
format,
data: result.data,

View File

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

View File

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

View File

@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
limitRaw: flags.limit,
format,
},
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
return table({ format, data: result.data })
}

View File

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

View File

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

View File

@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand {
const { flags } = this.parse(GetWorkspace, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
if (result.kind === 'empty')
return raw(result.message)
return table({

View File

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

View File

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

View File

@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand {
stream: flags.stream,
think: flags.think,
},
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
)
}
}

View File

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

View File

@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand {
stream: flags.stream,
think: flags.think,
},
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
)
}

View File

@ -1,5 +1,5 @@
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -13,17 +13,18 @@ import { bufferStreams } from '../../../sys/io/streams'
import { resumeApp } from '../../resume/app/run.js'
import { runApp } from './run.js'
function bundle(): HostsBundle {
function active(): ActiveContext {
return {
current_host: 'http://localhost',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
host: 'http://localhost',
email: 't@d.ai',
ctx: {
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
},
}
}
@ -51,7 +52,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-1', message: 'hi' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: hi\n')
expect(io.errBuf()).toContain('--conversation conv-1')
@ -62,7 +63,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await expect(runApp(
{ appId: 'app-2', message: 'hi' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
})
@ -71,7 +72,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' } },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
@ -81,7 +82,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-1', message: 'hi', format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
expect(parsed.mode).toBe('chat')
@ -92,7 +93,7 @@ describe('runApp', () => {
const io = bufferStreams()
await expect(runApp(
{ appId: 'app-1', format: 'bogus' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/not supported/)
})
@ -101,7 +102,7 @@ describe('runApp', () => {
await expect(runApp(
{ appId: 'nope', message: 'hi' },
{
bundle: bundle(),
active: active(),
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
io,
@ -114,7 +115,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('echo: ')
expect(io.outBuf()).toContain('hi')
@ -126,7 +127,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
expect(parsed.mode).toBe('chat')
@ -139,7 +140,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('do research')
expect(io.errBuf()).toContain('--conversation conv-1')
@ -150,7 +151,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toContain('go')
expect(io.errBuf()).toContain('thought:')
@ -161,7 +162,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
expect(parsed.mode).toBe('workflow')
@ -174,7 +175,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await expect(runApp(
{ appId: 'app-1', message: 'hi', stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
)).rejects.toMatchObject({ code: 'server_5xx' })
})
@ -186,7 +187,7 @@ describe('runApp', () => {
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
await runApp(
{ appId: 'app-2', inputsFile },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
@ -198,7 +199,7 @@ describe('runApp', () => {
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
await expect(runApp(
{ appId: 'app-2', inputsFile },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/must be a JSON object/)
})
@ -207,7 +208,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
})
@ -219,7 +220,7 @@ describe('runApp', () => {
await writeFile(inputsFile, '{}')
await expect(runApp(
{ appId: 'app-2', inputsJson: '{}', inputsFile },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
)).rejects.toThrow(/mutually exclusive/)
})
@ -231,7 +232,7 @@ describe('runApp', () => {
await expect(runApp(
{ appId: 'app-2', inputs: {} },
{
bundle: bundle(),
active: active(),
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
host: mock.url,
io,
@ -260,7 +261,7 @@ describe('runApp', () => {
await expect(runApp(
{ appId: 'app-2', inputs: {}, format: 'json' },
{
bundle: bundle(),
active: active(),
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
host: mock.url,
io,
@ -284,7 +285,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
@ -295,7 +296,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: resumed\n')
})
@ -306,7 +307,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await resumeApp(
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
// stream mode for workflow: node_started → "→ <title>" on stderr
expect(io.errBuf()).toContain('After Resume')
@ -317,7 +318,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
expect(mock.uploadCallCount).toBe(0)
@ -338,7 +339,7 @@ describe('runApp', () => {
await writeFile(filePath, 'fake pdf content')
await runApp(
{ appId: 'app-2', files: [`doc=@${filePath}`] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
expect(mock.uploadCallCount).toBe(1)
@ -355,7 +356,7 @@ describe('runApp', () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
await runApp(
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
)
expect(io.outBuf()).toBe('echo: \n')
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>

View File

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

View File

@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runSetMember(
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,28 +3,36 @@ import type {
WorkspaceListResponse,
} from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { ActiveContext } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { Registry } from '../../../auth/hosts.js'
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
import { bufferStreams } from '../../../sys/io/streams.js'
import { runUseWorkspace } from './use.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
function makeRegistry(): Registry {
const reg = Registry.empty('file')
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
],
}
})
reg.setHost('cloud.dify.ai')
reg.setAccount('tester@dify.ai')
return reg
}
function makeActive(reg: Registry): ActiveContext {
const active = reg.resolveActive()
if (active === undefined)
throw new Error('resolveActive returned undefined in test setup')
return active
}
function fakeClient(opts: {
@ -68,14 +76,16 @@ describe('runUseWorkspace', () => {
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
bundle: b,
reg,
active,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -84,40 +94,65 @@ describe('runUseWorkspace', () => {
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
expect(client.list).toHaveBeenCalledOnce()
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
expect(next.available_workspaces).toEqual([
const activeCtx = next.resolveActive()
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
expect(activeCtx?.ctx.available_workspaces).toEqual([
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = loadHosts()
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
const reloaded = Registry.load()
const reloadedActive = reloaded?.resolveActive()
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
})
it('refreshes stale workspace name from server', async () => {
// bundle has ws-2 named "Stale Name"; server returns "Switched".
// We expect saveHosts to record the fresh name from the server.
it('hosts.yml contains no bearer after switch', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = loadHosts()
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
const reloaded = Registry.load()
const raw = JSON.stringify(reloaded)
expect(raw).not.toMatch(/bearer/)
})
it('refreshes stale workspace name from server', async () => {
// registry has ws-2 named "Stale Name"; server returns "Switched".
// We expect saveRegistry to record the fresh name from the server.
const io = bufferStreams()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = Registry.load()
const reloadedActive = reloaded?.resolveActive()
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const before = Registry.load()
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
@ -127,7 +162,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
bundle: b,
reg,
active,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -136,16 +172,18 @@ describe('runUseWorkspace', () => {
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = loadHosts()
const after = Registry.load()
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
const afterActive = after?.resolveActive()
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
})
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const before = loadHosts()
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const before = Registry.load()
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
@ -155,7 +193,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
bundle: b,
reg,
active,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
@ -163,14 +202,15 @@ describe('runUseWorkspace', () => {
),
).rejects.toThrow(/transient list failure/)
const after = loadHosts()
const after = Registry.load()
expect(after).toEqual(before)
})
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
const io = bufferStreams()
const b = bundle()
saveHosts(b)
const reg = makeRegistry()
reg.save()
const active = makeActive(reg)
const client = fakeClient({
switch: () => Promise.resolve({
@ -192,7 +232,8 @@ describe('runUseWorkspace', () => {
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
bundle: b,
reg,
active,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,

View File

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

View File

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

153
cli/src/sys/io/select.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -362,6 +362,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
token_id: 'tok-sso-1',
})
}
if (scenario === 'no-email') {
return c.json({
token: 'dfoa_test',
subject_type: 'account',
account: { id: ACCOUNT.id, email: '', name: '' },
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
default_workspace_id: 'ws-1',
token_id: 'tok-1',
})
}
return c.json({
token: 'dfoa_test',
subject_type: 'account',

View File

@ -147,11 +147,6 @@
"count": 1
}
},
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
"react/set-state-in-effect": {
"count": 1
@ -248,11 +243,6 @@
"count": 1
}
},
"web/app/components/app-sidebar/nav-link/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -3172,16 +3162,6 @@
"count": 2
}
},
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/snippets/hooks/use-snippet-run.ts": {
"no-restricted-imports": {
"count": 2
}
},
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
@ -3352,11 +3332,6 @@
"count": 1
}
},
"web/app/components/workflow/block-selector/blocks.tsx": {
"unused-imports/no-unused-imports": {
"count": 1
}
},
"web/app/components/workflow/block-selector/hooks.ts": {
"react/set-state-in-effect": {
"count": 1
@ -5240,11 +5215,6 @@
"count": 1
}
},
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control.ts": {
"@tanstack/query/exhaustive-deps": {
"count": 1
@ -5510,11 +5480,6 @@
"count": 3
}
},
"web/service/use-snippet-workflows.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/use-tools.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 751 B

View File

@ -1,5 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 763 B

View File

@ -1,3 +0,0 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
</svg>

Before

Width:  |  Height:  |  Size: 526 B

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

View File

@ -513,27 +513,12 @@
"width": 14,
"height": 14
},
"line-others-dhs": {
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
"width": 18,
"height": 18
},
"line-others-drag-handle": {
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
},
"line-others-dvs": {
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
"width": 18,
"height": 18
},
"line-others-env": {
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
},
"line-others-evaluation": {
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
"width": 18,
"height": 18
},
"line-others-global-variable": {
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
},
@ -1040,11 +1025,6 @@
"workflow-if-else": {
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
},
"workflow-input-field": {
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
"width": 16,
"height": 16
},
"workflow-iteration": {
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 281,
"total": 277,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -341,11 +341,16 @@ describe('App List Browsing Flow', () => {
// -- Tab navigation --
describe('Tab Navigation', () => {
it('should render the app type dropdown trigger', () => {
it('should render all category tabs', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
})
@ -376,19 +381,21 @@ describe('App List Browsing Flow', () => {
// -- "Created by me" filter --
describe('Created By Me Filter', () => {
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
it('should render the "created by me" checkbox', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should keep the current layout stable without a "created by me" control', () => {
it('should toggle the "created by me" filter on click', () => {
mockPages = [createPage([createMockApp()])]
renderList()
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
fireEvent.click(checkbox)
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})

View File

@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@ -1,11 +0,0 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@ -1,21 +0,0 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@ -1,11 +0,0 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@ -1,7 +0,0 @@
import SnippetList from '@/app/components/snippet-list'
const SnippetsPage = () => {
return <SnippetList />
}
export default SnippetsPage

View File

@ -168,21 +168,6 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@ -28,16 +28,12 @@ type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
appInfoActions?: AppInfoActions
}
const AppDetailNav = ({
navigation,
extraInfo,
renderHeader,
renderNavigation,
iconType = 'app',
appInfoActions,
}: IAppDetailNavProps) => {
@ -116,20 +112,18 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{renderHeader
? renderHeader(appSidebarExpand)
: iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{!renderHeader && iconType !== 'app' && (
{iconType === 'app' && (
appInfoActions
? (
<AppInfoView
expand={expand}
actions={appInfoActions}
renderDetail={false}
/>
)
: <AppInfo expand={expand} />
)}
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@ -158,20 +152,18 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{renderNavigation
? renderNavigation(appSidebarExpand)
: navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>

View File

@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href?: string
href: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@ -31,8 +29,6 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@ -43,11 +39,8 @@ const NavLink = ({
return res
})()
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@ -77,32 +70,13 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={linkClassName}
className={cn(isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@ -1,270 +0,0 @@
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@ -1,60 +0,0 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
updatedAt: '2026-03-25 10:00',
usage: '12',
tags: [],
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@ -1,177 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
}), [snippet.description, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description }: {
name: string
description: string
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
variant="destructive"
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-100">
<div className="space-y-2 p-6">
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@ -1,46 +0,0 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
if (!expand)
return null
return (
<div className="flex flex-col px-2 pt-2 pb-1">
<div className="flex flex-col gap-2 rounded-xl p-2">
<div className="flex items-center justify-end">
<SnippetInfoDropdown snippet={snippet} />
</div>
<div className="min-w-0">
<div className="truncate system-md-semibold text-text-secondary">
{snippet.name}
</div>
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
{t('typeLabel')}
</div>
</div>
{snippet.description && (
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@ -1,6 +1,7 @@
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type { Features, FileUpload } from '@/app/components/base/features/types'
import type { ModelConfig } from '@/models/debug'
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
AlertDialog,
AlertDialogActions,
@ -20,15 +21,9 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { Resolution } from '@/types/app'
type PublishedModelConfig = ModelConfig & {
resetAppConfig?: () => void
}
type Props = Omit<AppPublisherProps, 'onPublish'> & {
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
publishedConfig: {
modelConfig: PublishedModelConfig
}
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
publishedConfig?: any
resetAppConfig?: () => void
}
@ -76,7 +71,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
setRestoreConfirmOpen(false)
}, [featuresStore, props])
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
return props.onPublish?.(params, features)
}, [features, props])

View File

@ -85,10 +85,8 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
type AppPublisherPublishHandler
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
| ((params?: unknown) => Promise<unknown> | unknown)
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown

View File

@ -211,12 +211,6 @@ describe('ConfigModalFormFields', () => {
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
textInputView.unmount()
const hiddenFieldDisabledProps = createBaseProps()
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
hiddenFieldDisabledView.unmount()
const singleFileProps = createBaseProps()
singleFileProps.tempPayload = {
...singleFileProps.tempPayload,

View File

@ -49,7 +49,6 @@ type ConfigModalFormFieldsProps = {
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
options?: string[]
selectOptions: SelectOptionItem[]
showHiddenField?: boolean
tempPayload: InputVar
t: Translate
}
@ -68,7 +67,6 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
onVarNameChange,
options,
selectOptions,
showHiddenField = true,
tempPayload,
t,
}) => {
@ -244,7 +242,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
</label>
{showHiddenField && !isFileInput && (
{!isFileInput && (
<div className="mt-5! flex h-6 items-center gap-2">
<label className="flex items-center gap-2">
<Checkbox

View File

@ -33,7 +33,6 @@ type IConfigModalProps = {
onClose: () => void
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
supportFile?: boolean
showHiddenField?: boolean
}
const ConfigModal: FC<IConfigModalProps> = ({
@ -42,7 +41,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
isShow,
onClose,
onConfirm,
showHiddenField,
supportFile,
}) => {
const { modelConfig } = useContext(ConfigContext)
@ -175,7 +173,6 @@ const ConfigModal: FC<IConfigModalProps> = ({
onVarNameChange={handleVarNameChange}
options={options}
selectOptions={selectOptions}
showHiddenField={showHiddenField}
tempPayload={tempPayload}
t={t}
/>

View File

@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: {} as CompletionParams,
})
const {

View File

@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
mode: mode as unknown as ModelModeType,
mode: mode as unknown as ModelModeType.chat,
completion_params: defaultCompletionParams,
})
const {

View File

@ -1,6 +1,5 @@
'use client'
import type { ComponentProps } from 'react'
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
@ -22,6 +21,7 @@ import type {
TextToSpeechConfig,
} from '@/models/debug'
import type { VisionSettings } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useBoolean, useGetState } from 'ahooks'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
resolvedModelModeType,
])
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
? params
: undefined

View File

@ -346,40 +346,29 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const previewInfo = (() => {
switch (mode) {
case AppModeEnum.CHAT:
return {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
}
case AppModeEnum.ADVANCED_CHAT:
return {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
}
case AppModeEnum.AGENT_CHAT:
return {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
}
case AppModeEnum.COMPLETION:
return {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
}
case AppModeEnum.WORKFLOW:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
default:
return {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
}
}
})()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
return (
<div className="px-8 py-4">
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>

View File

@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import * as React from 'react'
import Empty from '../empty'
const defaultMessage = 'workflow.tabs.noSnippetsFound'
describe('Empty', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -11,32 +9,32 @@ describe('Empty', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should render 36 placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
expect(placeholderCards).toHaveLength(36)
})
it('should display the provided message', () => {
render(<Empty message="app.newApp.noAppsFound" />)
it('should display the no apps found message', () => {
render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})
describe('Styling', () => {
it('should have correct container styling for overlay', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const overlay = container.querySelector('.pointer-events-none')
expect(overlay).toBeInTheDocument()
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
})
it('should have correct styling for placeholder cards', () => {
const { container } = render(<Empty message={defaultMessage} />)
const { container } = render(<Empty />)
const card = container.querySelector('.bg-background-default-lighter')
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
})
@ -44,10 +42,10 @@ describe('Empty', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = render(<Empty message={defaultMessage} />)
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
const { rerender } = render(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
rerender(<Empty message="app.newApp.noAppsFound" />)
rerender(<Empty />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
})

View File

@ -47,22 +47,16 @@ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
userProfile: { id: 'creator-1' },
}),
}))
const mockSetKeywords = vi.fn()
<<<<<<< HEAD
const mockSetTagIDs = vi.fn()
const mockSetCreatorID = vi.fn()
=======
const mockSetIsCreatedByMe = vi.fn()
>>>>>>> main
const mockSetCategory = vi.fn()
const mockQueryState = {
category: 'all',
keywords: '',
creatorID: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
@ -70,23 +64,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
query: mockQueryState,
setCategory: mockSetCategory,
setKeywords: mockSetKeywords,
<<<<<<< HEAD
setTagIDs: mockSetTagIDs,
setCreatorID: mockSetCreatorID,
}),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
],
},
=======
setIsCreatedByMe: mockSetIsCreatedByMe,
>>>>>>> main
}),
}))
@ -221,9 +199,9 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../new-app-card', () => ({
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
},
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}),
}))
vi.mock('../empty', () => ({
@ -260,20 +238,12 @@ beforeAll(() => {
// Render helper wrapping with shared nuqs testing helper plus a seeded
// systemFeatures cache so List can resolve its useSuspenseQuery.
<<<<<<< HEAD
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
=======
const renderList = (searchParams = '') => {
mockSearchParams = new URLSearchParams(searchParams)
>>>>>>> main
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
systemFeatures: { branding: { enabled: false } },
})
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
}
const openTypeFilter = () => {
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
}
type AppListInfiniteOptions = {
@ -294,7 +264,7 @@ describe('List', () => {
mockServiceState.isFetchingNextPage = false
mockQueryState.category = 'all'
mockQueryState.keywords = ''
mockQueryState.creatorID = ''
mockQueryState.isCreatedByMe = false
mockUseWorkflowOnlineUsers.mockClear()
intersectionCallback = null
localStorage.clear()
@ -303,12 +273,11 @@ describe('List', () => {
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should render app type dropdown with all app types', () => {
it('should render tab slider with all app types', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -328,21 +297,9 @@ describe('List', () => {
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
})
it('should render creators filter', () => {
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
})
it('should render link to snippets on apps page', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
})
it('should not render link to snippets on snippets page', () => {
renderList('', 'snippets')
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
@ -377,22 +334,20 @@ describe('List', () => {
})
})
describe('Type Filter', () => {
it('should update category when workflow type is selected', () => {
describe('Tab Navigation', () => {
it('should update category when workflow tab is clicked', () => {
renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
fireEvent.click(screen.getByText('app.types.workflow'))
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
})
it('should update category when all type is selected', () => {
it('should update category when all tab is clicked', () => {
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
fireEvent.click(screen.getByText('app.types.all'))
expect(mockSetCategory).toHaveBeenCalledWith('all')
})
@ -418,7 +373,10 @@ describe('List', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
const clearButton = document.querySelector('.group')
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetKeywords).toHaveBeenCalledWith('')
})
@ -427,7 +385,7 @@ describe('List', () => {
describe('App List Query', () => {
it('should build paged query input from active filters', () => {
mockQueryState.keywords = 'sales'
mockQueryState.creatorID = 'creator-1'
mockQueryState.isCreatedByMe = true
mockQueryState.category = AppModeEnum.WORKFLOW
renderList()
@ -441,7 +399,7 @@ describe('List', () => {
limit: 30,
name: 'sales',
tag_ids: ['tag-1'],
creator_id: 'creator-1',
is_created_by_me: true,
mode: AppModeEnum.WORKFLOW,
},
})
@ -468,19 +426,19 @@ describe('List', () => {
})
})
describe('Creators Filter', () => {
it('should render creators filter with correct label', () => {
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
it('should handle creator selection as a single creator filter', () => {
it('should handle checkbox change', () => {
renderList()
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
fireEvent.click(checkbox)
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
})
})
@ -526,11 +484,11 @@ describe('List', () => {
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { unmount } = renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
unmount()
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
})
it('should render app cards correctly', () => {
@ -543,10 +501,9 @@ describe('List', () => {
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
})
})
@ -563,10 +520,9 @@ describe('List', () => {
})
})
describe('App Type Dropdown', () => {
it('should render all app type options', () => {
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
openTypeFilter()
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
@ -576,7 +532,9 @@ describe('List', () => {
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
})
it('should update category for each app type option click', () => {
it('should update category for each app type tab click', () => {
renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
@ -587,11 +545,8 @@ describe('List', () => {
for (const { mode, text } of appTypeTexts) {
mockSetCategory.mockClear()
const { unmount } = renderList()
openTypeFilter()
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
fireEvent.click(screen.getByText(text))
expect(mockSetCategory).toHaveBeenCalledWith(mode)
unmount()
}
})
})

View File

@ -1,16 +0,0 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
export type { AppListCategory }
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

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