Compare commits

...
5857 changed files with 194190 additions and 163145 deletions
+11 -1
View File
@@ -28,6 +28,7 @@ Follow these steps when using this skill:
3. Compose the final output strictly follow the **Required Output Format**.
Notes when using this skill:
- Always include actionable fixes or suggestions (including possible code snippets).
- Use best-effort `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
@@ -43,6 +44,7 @@ Notes when using this skill:
### 1. Security Review
Check for:
- SQL injection vulnerabilities
- Server-Side Request Forgery (SSRF)
- Command injection
@@ -54,6 +56,7 @@ Check for:
### 2. Performance Review
Check for:
- N+1 queries
- Missing database indexes
- Memory leaks
@@ -63,6 +66,7 @@ Check for:
### 3. Code Quality Review
Check for:
- Code forward compatibility
- Code duplication (DRY violations)
- Functions doing too much (SRP violations)
@@ -75,6 +79,7 @@ Check for:
### 4. Testing Review
Check for:
- Missing test coverage for new code
- Tests that don't test behavior
- Flaky test patterns
@@ -108,6 +113,7 @@ FilePath: <path> line <line>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each critical issue) ...
Found <Y> suggestions for improvement:
@@ -129,11 +135,13 @@ FilePath: <path> line <line>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each suggestion) ...
Found <Z> optional nits:
## 🟢 Nits (Optional)
### 1. <brief description of the nit>
FilePath: <path> line <line>
@@ -148,6 +156,7 @@ FilePath: <path> line <line>
- <minor suggestions>
---
... (repeat for each nits) ...
## ✅ What's Good
@@ -164,5 +173,6 @@ FilePath: <path> line <line>
```markdown
## Code Review Summary
✅ No issues found.
```
```
@@ -1,11 +1,13 @@
# Rule Catalog — Architecture
## Scope
- Covers: controller/service/core-domain/libs/model layering, dependency direction, responsibility placement, observability-friendly flow.
## Rules
### Keep business logic out of controllers
- Category: maintainability
- Severity: critical
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
@@ -33,12 +35,14 @@
```
### Preserve layer dependency direction
- Category: best practices
- Severity: critical
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
- Example:
- Bad:
```python
# core/policy/publish_policy.py
from controllers.console.app import request_context
@@ -46,7 +50,9 @@
def can_publish() -> bool:
return request_context.current_user.is_admin
```
- Good:
```python
# core/policy/publish_policy.py
def can_publish(role: str) -> bool:
@@ -57,6 +63,7 @@
```
### Keep libs business-agnostic
- Category: maintainability
- Severity: critical
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
@@ -65,6 +72,7 @@
- Keep `libs` dependencies clean: avoid importing service/controller/domain-specific modules into `api/libs/`.
- Example:
- Bad:
```python
# api/libs/conversation_filter.py
from services.conversation_service import ConversationService
@@ -76,7 +84,9 @@
return conversation.idle_days > 90
return conversation.idle_days > 30
```
- Good:
```python
# api/libs/datetime_utils.py (business-agnostic helper)
def older_than_days(idle_days: int, threshold_days: int) -> bool:
@@ -88,4 +98,4 @@
def should_archive_conversation(conversation, tenant_id: str) -> bool:
threshold_days = 90 if has_paid_plan(tenant_id) else 30
return older_than_days(conversation.idle_days, threshold_days)
```
```
@@ -1,12 +1,14 @@
# Rule Catalog — DB Schema Design
## Scope
- Covers: model/base inheritance, schema boundaries in model properties, tenant-aware schema design, index redundancy checks, dialect portability in models, and cross-database compatibility in migrations.
- Does NOT cover: session lifecycle, transaction boundaries, and query execution patterns (handled by `sqlalchemy-rule.md`).
## Rules
### Do not query other tables inside `@property`
- Category: [maintainability, performance]
- Severity: critical
- Description: A model `@property` must not open sessions or query other tables. This hides dependencies across models, tightly couples schema objects to data access, and can cause N+1 query explosions when iterating collections.
@@ -16,6 +18,7 @@
- For list/batch reads, fetch required related data explicitly (join/preload/bulk query) before rendering derived values.
- Example:
- Bad:
```python
class Conversation(TypeBase):
__tablename__ = "conversations"
@@ -26,7 +29,9 @@
app = session.execute(select(App).where(App.id == self.app_id)).scalar_one()
return app.name
```
- Good:
```python
class Conversation(TypeBase):
__tablename__ = "conversations"
@@ -40,6 +45,7 @@
```
### Prefer including `tenant_id` in model definitions
- Category: maintainability
- Severity: suggestion
- Description: In multi-tenant domains, include `tenant_id` in schema definitions whenever the entity belongs to tenant-owned data. This improves data isolation safety and keeps future partitioning/sharding strategies practical as data volume grows.
@@ -49,6 +55,7 @@
- Exception: if a table is explicitly designed as non-tenant-scoped global metadata, document that design decision clearly.
- Example:
- Bad:
```python
from sqlalchemy.orm import Mapped
@@ -57,7 +64,9 @@
id: Mapped[str] = mapped_column(StringUUID, primary_key=True)
name: Mapped[str] = mapped_column(sa.String(255), nullable=False)
```
- Good:
```python
from sqlalchemy.orm import Mapped
@@ -69,6 +78,7 @@
```
### Detect and avoid duplicate/redundant indexes
- Category: performance
- Severity: suggestion
- Description: Review index definitions for leftmost-prefix redundancy. For example, index `(a, b, c)` can safely cover most lookups for `(a, b)`. Keeping both may increase write overhead and can mislead the optimizer into suboptimal execution plans.
@@ -93,6 +103,7 @@
```
### Avoid PostgreSQL-only dialect usage in models; wrap in `models.types`
- Category: maintainability
- Severity: critical
- Description: Model/schema definitions should avoid PostgreSQL-only constructs directly in business models. When database-specific behavior is required, encapsulate it in `api/models/types.py` using both PostgreSQL and MySQL dialect implementations, then consume that abstraction from model code.
@@ -101,6 +112,7 @@
- Add or extend wrappers in `models.types` (for example, `AdjustedJSON`, `LongText`, `BinaryData`) to normalize behavior across PostgreSQL and MySQL.
- Example:
- Bad:
```python
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped
@@ -109,7 +121,9 @@
__tablename__ = "tool_configs"
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
```
- Good:
```python
from sqlalchemy.orm import Mapped
@@ -121,6 +135,7 @@
```
### Guard migration incompatibilities with dialect checks and shared types
- Category: maintainability
- Severity: critical
- Description: Migration scripts under `api/migrations/versions/` must account for PostgreSQL/MySQL incompatibilities explicitly. For dialect-sensitive DDL or defaults, branch on the active dialect (for example, `conn.dialect.name == "postgresql"`), and prefer reusable compatibility abstractions from `models.types` where applicable.
@@ -142,6 +157,7 @@
)
```
- Good:
```python
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
@@ -1,17 +1,19 @@
# Rule Catalog - Repositories Abstraction
## Scope
- Covers: when to reuse existing repository abstractions, when to introduce new repositories, and how to preserve dependency direction between service/core and infrastructure implementations.
- Does NOT cover: SQLAlchemy session lifecycle and query-shape specifics (handled by `sqlalchemy-rule.md`), and table schema/migration design (handled by `db-schema-rule.md`).
## Rules
### Introduce repositories abstraction
- Category: maintainability
- Severity: suggestion
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
- Suggested fix:
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
- If no repository exists, add one only when complexity warrants it (for example, repeated complex queries, large data domains, or multiple storage strategies), while preserving dependency direction (service/core depends on abstraction; infra provides implementation).
- Example:
- Bad:
@@ -26,6 +28,7 @@
self.session.commit()
```
- Good:
```python
# Case A: Existing repository must be reused for all table operations.
class AppService:
@@ -37,6 +40,7 @@
# If the query is missing, extend the existing abstraction.
active_apps = self.app_repo.list_active_for_tenant(tenant_id=tenant_id)
```
- Bad:
```python
# No repository exists, but large-domain query logic is scattered in service code.
@@ -46,6 +50,7 @@
# many filters/joins/pagination variants duplicated across services
```
- Good:
```python
# Case B: Introduce repository for large/complex domains or storage variation.
class ConversationRepository(Protocol):
@@ -1,12 +1,14 @@
# Rule Catalog — SQLAlchemy Patterns
## Scope
- Covers: SQLAlchemy session and transaction lifecycle, query construction, tenant scoping, raw SQL boundaries, and write-path concurrency safeguards.
- Does NOT cover: table/model schema and migration design details (handled by `db-schema-rule.md`).
## Rules
### Use Session context manager with explicit transaction control behavior
- Category: best practices
- Severity: critical
- Description: Session and transaction lifecycle must be explicit and bounded on write paths. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention, lock duration, and deadlock risk.
@@ -16,6 +18,7 @@
- Keep transaction windows short: avoid network I/O, heavy computation, or unrelated work inside the transaction.
- Example:
- Bad:
```python
# Missing commit: write may never be persisted.
with Session(db.engine, expire_on_commit=False) as session:
@@ -28,7 +31,9 @@
run.status = "cancelled"
call_external_api()
```
- Good:
```python
# Option 1: explicit commit.
with Session(db.engine, expire_on_commit=False) as session:
@@ -46,6 +51,7 @@
```
### Enforce tenant_id scoping on shared-resource queries
- Category: security
- Severity: critical
- Description: Reads and writes against shared tables must be scoped by `tenant_id` to prevent cross-tenant data leakage or corruption.
@@ -66,6 +72,7 @@
```
### Prefer SQLAlchemy expressions over raw SQL by default
- Category: maintainability
- Severity: suggestion
- Description: Raw SQL should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, and more consistent with the codebase.
@@ -88,6 +95,7 @@
```
### Protect write paths with concurrency safeguards
- Category: quality
- Severity: critical
- Description: Multi-writer paths without explicit concurrency control can silently overwrite data. Choose the safeguard based on contention level, lock scope, and throughput cost instead of defaulting to one strategy.
@@ -104,6 +112,7 @@
session.commit() # silently overwrites concurrent updates
```
- Good:
```python
# 1) Optimistic lock (low contention, retry on conflict)
result = session.execute(
@@ -136,4 +145,4 @@
).scalar_one()
run.status = "cancelled"
session.commit()
```
```
+31 -22
View File
@@ -46,12 +46,12 @@ pnpm analyze-component <path> --json
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
| Score | Level | Action |
| ------ | --------------- | --------------------------- |
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
## Core Refactoring Patterns
@@ -67,9 +67,9 @@ function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// 50+ lines of state management logic...
return <div>...</div>
}
@@ -78,9 +78,9 @@ function Configuration() {
export const useModelConfig = (appId: string) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// Related state management logic here
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
}
@@ -92,6 +92,7 @@ function Configuration() {
```
**Dify Examples**:
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
- `web/app/components/app/configuration/debug/hooks.tsx`
- `web/app/components/workflow/hooks/use-workflow.ts`
@@ -123,7 +124,7 @@ const AppInfo = () => {
const AppInfo = () => {
const { showModal, setShowModal } = useAppInfoModals()
return (
<div>
<AppHeader appDetail={appDetail} />
@@ -135,6 +136,7 @@ const AppInfo = () => {
```
**Dify Examples**:
- `web/app/components/app/configuration/` directory structure
- `web/app/components/workflow/nodes/` per-node organization
@@ -177,7 +179,7 @@ const TEMPLATE_MAP = {
const Template = useMemo(() => {
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
if (!modeTemplates) return null
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
@@ -188,11 +190,13 @@ const Template = useMemo(() => {
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Dify Convention**:
- This skill is for component decomposition, not query/mutation design.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
**Dify Examples**:
- `web/service/use-workflow.ts`
- `web/service/use-common.ts`
- `web/service/knowledge/use-dataset.ts`
@@ -220,10 +224,10 @@ type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
const useAppInfoModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
@@ -248,7 +252,7 @@ const ConfigForm = () => {
defaultValues: { name: '', description: '' },
onSubmit: handleSubmit,
})
return <form.Provider>...</form.Provider>
}
```
@@ -285,6 +289,7 @@ return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
**Conventions**:
- Keep node logic in `use-interactions.ts`
- Extract panel UI to separate files
- Use `_base` components for common patterns
@@ -303,6 +308,7 @@ nodes/<node-type>/
**When**: Refactoring app configuration components.
**Conventions**:
- Separate config sections into subdirectories
- Use existing patterns from `web/app/components/app/configuration/`
- Keep feature toggles in dedicated components
@@ -312,6 +318,7 @@ nodes/<node-type>/
**When**: Refactoring tool-related components (`web/app/components/tools/`).
**Conventions**:
- Follow existing modal patterns
- Use service hooks from `web/service/use-tools.ts`
- Keep provider-specific logic isolated
@@ -325,6 +332,7 @@ pnpm refactor-component <path>
```
This command will:
- Analyze component complexity and features
- Identify specific refactoring actions needed
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
@@ -337,6 +345,7 @@ pnpm analyze-component <path> --json
```
Identify:
- Total complexity score
- Max function complexity
- Line count
@@ -346,13 +355,13 @@ Identify:
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
| Detected Feature | Refactoring Action |
| ------------------------------------- | -------------------------- |
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
### Step 4: Execute Incrementally
@@ -13,16 +13,16 @@ The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
| Pattern | Complexity Impact |
| ------------------- | -------------------- | -------- | --------------- |
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/` | | ` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
@@ -85,10 +85,10 @@ const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplatePro
// Clean component logic
const Template = useMemo(() => {
if (!appDetail?.mode) return null
const templates = TEMPLATE_MAP[appDetail.mode]
if (!templates) return null
const TemplateComponent = templates[locale] ?? templates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
@@ -124,17 +124,17 @@ const handleSubmit = () => {
showValidationError()
return
}
if (!hasChanges) {
showNoChangesMessage()
return
}
if (!isConnected) {
showConnectionError()
return
}
submitData()
}
```
@@ -146,12 +146,10 @@ const handleSubmit = () => {
```typescript
const canPublish = (() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return true
if (!isAdvancedMode) return true
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
return false
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query) return false
return true
}
return true
@@ -173,9 +171,8 @@ const canPublishInChatMode = () => {
}
// Clean main logic
const canPublish = mode === AppModeEnum.COMPLETION
? canPublishInCompletionMode()
: canPublishInChatMode()
const canPublish =
mode === AppModeEnum.COMPLETION ? canPublishInCompletionMode() : canPublishInChatMode()
```
## Pattern 4: Replace Chained Ternaries
@@ -232,7 +229,7 @@ const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
```typescript
const processData = (items: Item[]) => {
const results: ProcessedItem[] = []
for (const item of items) {
if (item.isValid) {
for (const child of item.children) {
@@ -250,7 +247,7 @@ const processData = (items: Item[]) => {
}
}
}
return results
}
```
@@ -261,19 +258,19 @@ const processData = (items: Item[]) => {
// Use functional approach
const processData = (items: Item[]) => {
return items
.filter(item => item.isValid)
.flatMap(item =>
.filter((item) => item.isValid)
.flatMap((item) =>
item.children
.filter(child => child.isActive)
.flatMap(child =>
.filter((child) => child.isActive)
.flatMap((child) =>
child.properties
.filter(prop => prop.value !== null)
.map(prop => ({
.filter((prop) => prop.value !== null)
.map((prop) => ({
itemId: item.id,
childId: child.id,
propValue: prop.value,
}))
)
})),
),
)
}
```
@@ -309,10 +306,10 @@ const Component = () => {
setDataSets(data)
}
hideSelectDataSet()
// 40 more lines of logic...
}
return <div>...</div>
}
```
@@ -325,7 +322,7 @@ const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[
const normalizeSelection = (data: DataSet[]) => {
const hasUnloadedItem = data.some(item => !item.name)
if (!hasUnloadedItem) return data
return produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
@@ -335,33 +332,33 @@ const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[
})
})
}
const hasSelectionChanged = (newData: DataSet[]) => {
return !isEqual(
newData.map(item => item.id),
dataSets.map(item => item.id)
)
}
return { normalizeSelection, hasSelectionChanged }
}
// Component becomes cleaner
const Component = () => {
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
const handleSelect = (data: DataSet[]) => {
if (!hasSelectionChanged(data)) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
const normalized = normalizeSelection(data)
setDataSets(normalized)
hideSelectDataSet()
}
return <div>...</div>
}
```
@@ -371,12 +368,13 @@ const Component = () => {
**Before** (complexity: ~8):
```typescript
const toggleDisabled = hasInsufficientPermissions
|| appUnpublished
|| missingStartNode
|| triggerModeDisabled
|| (isAdvancedApp && !currentWorkflow?.graph)
|| (isBasicApp && !basicAppConfig.updated_at)
const toggleDisabled =
hasInsufficientPermissions ||
appUnpublished ||
missingStartNode ||
triggerModeDisabled ||
(isAdvancedApp && !currentWorkflow?.graph) ||
(isBasicApp && !basicAppConfig.updated_at)
```
**After** (complexity: ~3):
@@ -425,8 +423,7 @@ const payload = useMemo(() => {
description: '',
type: item.value_type,
}))
}
else if (detail && detail.tool) {
} else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => ({
// Complex transformation...
}))
@@ -434,7 +431,7 @@ const payload = useMemo(() => {
// Complex transformation...
}))
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
@@ -450,7 +447,7 @@ const payload = useMemo(() => {
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
return useMemo(() => {
if (!published) {
return inputs.map(item => ({
return inputs.map((item) => ({
name: item.variable,
description: '',
form: 'llm',
@@ -458,15 +455,16 @@ const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, publishe
type: item.type,
}))
}
if (!detail?.tool) return []
return inputs.map(item => ({
return inputs.map((item) => ({
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
description:
detail.tool.parameters.find((p) => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find((p) => p.name === item.variable)?.form || 'llm',
}))
}, [inputs, detail, published])
}
@@ -475,21 +473,24 @@ const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, publishe
const parameters = useParameterTransform(inputs, detail, published)
const outputParameters = useOutputTransform(outputs, detail, published)
const payload = useMemo(() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}), [detail, icon, name, parameters, outputParameters])
const payload = useMemo(
() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}),
[detail, icon, name, parameters, outputParameters],
)
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | ≤ 3 levels |
| Conditional Chains | ≤ 3 conditions |
| Metric | Target |
| ----------------------- | -------------- |
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | ≤ 3 levels |
| Conditional Chains | ≤ 3 conditions |
@@ -32,17 +32,17 @@ const ConfigurationPage = () => {
<AppPublisher ... />
</div>
</div>
{/* Config Section - 200 lines */}
<div className="config">
<Config />
</div>
{/* Debug Section - 150 lines */}
<div className="debug">
<Debug ... />
</div>
{/* Modals Section - 100 lines */}
{showSelectDataSet && <SelectDataSet ... />}
{showHistoryModal && <EditHistoryModal ... />}
@@ -70,7 +70,7 @@ function ConfigurationHeader({
onPublish,
}: ConfigurationHeaderProps) {
const { t } = useTranslation()
return (
<div className="header">
<h1>{t('configuration.title')}</h1>
@@ -87,7 +87,7 @@ function ConfigurationHeader({
const ConfigurationPage = () => {
const { modelConfig, setModelConfig } = useModelConfig()
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
<ConfigurationHeader
@@ -175,15 +175,15 @@ const AppInfo = () => {
const [showDuplicate, setShowDuplicate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showSwitch, setShowSwitch] = useState(false)
const onEdit = async (data) => { /* 20 lines */ }
const onDuplicate = async (data) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main content */}
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
@@ -248,12 +248,12 @@ function AppInfoModals({
// Parent component
const AppInfo = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main content with openModal triggers */}
<Button onClick={() => openModal('edit')}>Edit</Button>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
@@ -418,7 +418,7 @@ Use callbacks for child-to-parent communication:
// Parent
const Parent = () => {
const [value, setValue] = useState('')
return (
<Child
value={value}
@@ -460,7 +460,7 @@ function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
@@ -81,9 +81,9 @@ export const useModelConfig = ({
// ... default values
...initialConfig,
})
const [completionParams, setCompletionParams] = useState<FormValue>({})
const modelModeType = modelConfig.mode
// Fill old app data missing model mode
@@ -91,9 +91,11 @@ export const useModelConfig = ({
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties?.mode
if (mode) {
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = mode
}))
setModelConfig(
produce(modelConfig, (draft) => {
draft.mode = mode
}),
)
}
}
}, [hasFetchedDetail, modelModeType, currModel])
@@ -129,7 +131,7 @@ function Configuration() {
currModel,
hasFetchedDetail,
})
// Component now focuses on UI
}
```
@@ -179,18 +181,21 @@ export const useConfigForm = (initialValues: ConfigFormValues) => {
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
setValues((prev) => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
const handleSubmit = useCallback(
async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
},
[values, validate],
)
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
@@ -233,7 +238,7 @@ export const useModalState = () => {
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const toggle = useCallback(() => setValue((v) => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
@@ -255,18 +260,22 @@ import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
const { result } = renderHook(() =>
useModelConfig({
hasFetchedDetail: false,
}),
)
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
const { result } = renderHook(() =>
useModelConfig({
hasFetchedDetail: true,
}),
)
act(() => {
result.current.setModelConfig({
@@ -1,4 +1,4 @@
interface:
display_name: "E2E Cucumber + Playwright"
short_description: "Write and review Dify E2E scenarios."
default_prompt: "Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/."
display_name: 'E2E Cucumber + Playwright'
short_description: 'Write and review Dify E2E scenarios.'
default_prompt: 'Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/.'
+14 -14
View File
@@ -93,10 +93,10 @@ describe('ComponentName', () => {
it('should render without crashing', () => {
// Arrange
const props = { title: 'Test' }
// Act
render(<Component {...props} />)
// Assert
expect(screen.getByText('Test')).toBeInTheDocument()
})
@@ -115,9 +115,9 @@ describe('ComponentName', () => {
it('should handle click events', () => {
const handleClick = vi.fn()
render(<Component onClick={handleClick} />)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
@@ -278,16 +278,16 @@ it('should disable input when isReadOnly is true')
### Conditional (When Present)
| Feature | Test Focus |
|---------|-----------|
| `useState` | Initial state, transitions, cleanup |
| `useEffect` | Execution, dependencies, cleanup |
| Event handlers | All onClick, onChange, onSubmit, keyboard |
| API calls | Loading, success, error states |
| Routing | Navigation, params, query strings |
| `useCallback`/`useMemo` | Referential equality |
| Context | Provider values, consumer behavior |
| Forms | Validation, submission, error display |
| Feature | Test Focus |
| ----------------------- | ----------------------------------------- |
| `useState` | Initial state, transitions, cleanup |
| `useEffect` | Execution, dependencies, cleanup |
| Event handlers | All onClick, onChange, onSubmit, keyboard |
| API calls | Loading, success, error states |
| Routing | Navigation, params, query strings |
| `useCallback`/`useMemo` | Referential equality |
| Context | Provider values, consumer behavior |
| Forms | Validation, submission, error display |
## Coverage Goals (Per File)
@@ -108,10 +108,8 @@ describe('ComponentName', () => {
it('should render without crashing', () => {
// Arrange - Setup data and mocks
// const props = createMockProps()
// Act - Render the component
// render(<ComponentName {...props} />)
// Assert - Verify expected output
// Prefer getByRole for accessibility; it's what users "see"
// expect(screen.getByRole('...')).toBeInTheDocument()
@@ -9,7 +9,7 @@ import { render, screen, waitFor } from '@testing-library/react'
it('should load and display data', async () => {
render(<DataComponent />)
// Wait for element to appear
await waitFor(() => {
expect(screen.getByText('Loaded Data')).toBeInTheDocument()
@@ -18,7 +18,7 @@ it('should load and display data', async () => {
it('should hide loading spinner after load', async () => {
render(<DataComponent />)
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByText('Loading...')).not.toBeInTheDocument()
@@ -31,11 +31,11 @@ it('should hide loading spinner after load', async () => {
```typescript
it('should show user name after fetch', async () => {
render(<UserProfile />)
// findBy returns a promise, auto-waits up to 1000ms
const userName = await screen.findByText('John Doe')
expect(userName).toBeInTheDocument()
// findByRole with options
const button = await screen.findByRole('button', { name: /submit/i })
expect(button).toBeEnabled()
@@ -50,13 +50,13 @@ import userEvent from '@testing-library/user-event'
it('should submit form', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<Form onSubmit={onSubmit} />)
// userEvent methods are async
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.click(screen.getByRole('button', { name: /submit/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' })
})
@@ -87,16 +87,16 @@ describe('Debounced Search', () => {
it('should debounce search input', async () => {
const onSearch = vi.fn()
render(<SearchInput onSearch={onSearch} debounceMs={300} />)
// Type in the input
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'query' } })
// Search not called immediately
expect(onSearch).not.toHaveBeenCalled()
// Advance timers
vi.advanceTimersByTime(300)
// Now search is called
expect(onSearch).toHaveBeenCalledWith('query')
})
@@ -111,23 +111,23 @@ it('should retry on failure', async () => {
const fetchData = vi.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ data: 'success' })
render(<RetryComponent fetchData={fetchData} retryDelayMs={1000} />)
// First call fails
await waitFor(() => {
expect(fetchData).toHaveBeenCalledTimes(1)
})
// Advance timer for retry
vi.advanceTimersByTime(1000)
// Second call succeeds
await waitFor(() => {
expect(fetchData).toHaveBeenCalledTimes(2)
expect(screen.getByText('success')).toBeInTheDocument()
})
vi.useRealTimers()
})
```
@@ -163,31 +163,31 @@ describe('DataFetcher', () => {
it('should show loading state', () => {
mockedApi.fetchData.mockImplementation(() => new Promise(() => {})) // Never resolves
render(<DataFetcher />)
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
it('should show data on success', async () => {
mockedApi.fetchData.mockResolvedValue({ items: ['Item 1', 'Item 2'] })
render(<DataFetcher />)
// Use findBy* for multiple async elements (better error messages than waitFor with multiple assertions)
const item1 = await screen.findByText('Item 1')
const item2 = await screen.findByText('Item 2')
expect(item1).toBeInTheDocument()
expect(item2).toBeInTheDocument()
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument()
})
it('should show error on failure', async () => {
mockedApi.fetchData.mockRejectedValue(new Error('Failed to fetch'))
render(<DataFetcher />)
await waitFor(() => {
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument()
})
@@ -195,16 +195,16 @@ describe('DataFetcher', () => {
it('should retry on error', async () => {
mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
render(<DataFetcher />)
await waitFor(() => {
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
mockedApi.fetchData.mockResolvedValue({ items: ['Item 1'] })
fireEvent.click(screen.getByRole('button', { name: /retry/i }))
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument()
})
@@ -218,19 +218,19 @@ describe('DataFetcher', () => {
it('should submit form and show success', async () => {
const user = userEvent.setup()
mockedApi.createItem.mockResolvedValue({ id: '1', name: 'New Item' })
render(<CreateItemForm />)
await user.type(screen.getByLabelText('Name'), 'New Item')
await user.click(screen.getByRole('button', { name: /create/i }))
// Button should be disabled during submission
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
await waitFor(() => {
expect(screen.getByText(/created successfully/i)).toBeInTheDocument()
})
expect(mockedApi.createItem).toHaveBeenCalledWith({ name: 'New Item' })
})
```
@@ -242,9 +242,9 @@ it('should submit form and show success', async () => {
```typescript
it('should fetch data on mount', async () => {
const fetchData = vi.fn().mockResolvedValue({ data: 'test' })
render(<ComponentWithEffect fetchData={fetchData} />)
await waitFor(() => {
expect(fetchData).toHaveBeenCalledTimes(1)
})
@@ -256,15 +256,15 @@ it('should fetch data on mount', async () => {
```typescript
it('should refetch when id changes', async () => {
const fetchData = vi.fn().mockResolvedValue({ data: 'test' })
const { rerender } = render(<ComponentWithEffect id="1" fetchData={fetchData} />)
await waitFor(() => {
expect(fetchData).toHaveBeenCalledWith('1')
})
rerender(<ComponentWithEffect id="2" fetchData={fetchData} />)
await waitFor(() => {
expect(fetchData).toHaveBeenCalledWith('2')
expect(fetchData).toHaveBeenCalledTimes(2)
@@ -279,13 +279,13 @@ it('should cleanup subscription on unmount', () => {
const subscribe = vi.fn()
const unsubscribe = vi.fn()
subscribe.mockReturnValue(unsubscribe)
const { unmount } = render(<SubscriptionComponent subscribe={subscribe} />)
expect(subscribe).toHaveBeenCalledTimes(1)
unmount()
expect(unsubscribe).toHaveBeenCalledTimes(1)
})
```
@@ -51,16 +51,16 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
### Conditional Sections (Add When Feature Present)
| Feature | Add Tests For |
|---------|---------------|
| `useState` | Initial state, transitions, cleanup |
| `useEffect` | Execution, dependencies, cleanup |
| Event handlers | onClick, onChange, onSubmit, keyboard |
| API calls | Loading, success, error states |
| Routing | Navigation, params, query strings |
| `useCallback`/`useMemo` | Referential equality |
| Context | Provider values, consumer behavior |
| Forms | Validation, submission, error display |
| Feature | Add Tests For |
| ----------------------- | ------------------------------------- |
| `useState` | Initial state, transitions, cleanup |
| `useEffect` | Execution, dependencies, cleanup |
| Event handlers | onClick, onChange, onSubmit, keyboard |
| API calls | Loading, success, error states |
| Routing | Navigation, params, query strings |
| `useCallback`/`useMemo` | Referential equality |
| Context | Provider values, consumer behavior |
| Forms | Validation, submission, error display |
## Code Quality Checklist
@@ -110,8 +110,8 @@ For the current file being tested:
- [ ] 100% function coverage
- [ ] 100% statement coverage
- [ ] >95% branch coverage
- [ ] >95% line coverage
- [ ] > 95% branch coverage
- [ ] > 95% line coverage
## Post-Generation (Per File)
@@ -107,13 +107,13 @@ describe('Counter', () => {
it('should increment count', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} />)
// Initial state
expect(screen.getByText('Count: 0')).toBeInTheDocument()
// Trigger transition
await user.click(screen.getByRole('button', { name: /increment/i }))
// New state
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
@@ -127,17 +127,17 @@ describe('ControlledInput', () => {
it('should call onChange with new value', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<ControlledInput value="" onChange={handleChange} />)
await user.type(screen.getByRole('textbox'), 'a')
expect(handleChange).toHaveBeenCalledWith('a')
})
it('should display controlled value', () => {
render(<ControlledInput value="controlled" onChange={vi.fn()} />)
expect(screen.getByRole('textbox')).toHaveValue('controlled')
})
})
@@ -149,26 +149,26 @@ describe('ControlledInput', () => {
describe('ConditionalComponent', () => {
it('should show loading state', () => {
render(<DataDisplay isLoading={true} data={null} />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
expect(screen.queryByTestId('data-content')).not.toBeInTheDocument()
})
it('should show error state', () => {
render(<DataDisplay isLoading={false} data={null} error="Failed to load" />)
expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
})
it('should show data when loaded', () => {
render(<DataDisplay isLoading={false} data={{ name: 'Test' }} />)
expect(screen.getByText('Test')).toBeInTheDocument()
})
it('should show empty state when no data', () => {
render(<DataDisplay isLoading={false} data={[]} />)
expect(screen.getByText(/no data/i)).toBeInTheDocument()
})
})
@@ -186,7 +186,7 @@ describe('ItemList', () => {
it('should render all items', () => {
render(<ItemList items={items} />)
expect(screen.getAllByRole('listitem')).toHaveLength(3)
items.forEach(item => {
expect(screen.getByText(item.name)).toBeInTheDocument()
@@ -196,17 +196,17 @@ describe('ItemList', () => {
it('should handle item selection', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(<ItemList items={items} onSelect={onSelect} />)
await user.click(screen.getByText('Item 2'))
expect(onSelect).toHaveBeenCalledWith(items[1])
})
it('should handle empty list', () => {
render(<ItemList items={[]} />)
expect(screen.getByText(/no items/i)).toBeInTheDocument()
})
})
@@ -218,55 +218,55 @@ describe('ItemList', () => {
describe('Modal', () => {
it('should not render when closed', () => {
render(<Modal isOpen={false} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('should render when open', () => {
render(<Modal isOpen={true} onClose={vi.fn()} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should call onClose when clicking overlay', async () => {
const user = userEvent.setup()
const handleClose = vi.fn()
render(<Modal isOpen={true} onClose={handleClose} />)
await user.click(screen.getByTestId('modal-overlay'))
expect(handleClose).toHaveBeenCalled()
})
it('should call onClose when pressing Escape', async () => {
const user = userEvent.setup()
const handleClose = vi.fn()
render(<Modal isOpen={true} onClose={handleClose} />)
await user.keyboard('{Escape}')
expect(handleClose).toHaveBeenCalled()
})
it('should trap focus inside modal', async () => {
const user = userEvent.setup()
render(
<Modal isOpen={true} onClose={vi.fn()}>
<button>First</button>
<button>Second</button>
</Modal>
)
// Focus should cycle within modal
await user.tab()
expect(screen.getByText('First')).toHaveFocus()
await user.tab()
expect(screen.getByText('Second')).toHaveFocus()
await user.tab()
expect(screen.getByText('First')).toHaveFocus() // Cycles back
})
@@ -280,13 +280,13 @@ describe('LoginForm', () => {
it('should submit valid form', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn()
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
@@ -295,39 +295,39 @@ describe('LoginForm', () => {
it('should show validation errors', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
// Submit empty form
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByText(/email is required/i)).toBeInTheDocument()
expect(screen.getByText(/password is required/i)).toBeInTheDocument()
})
it('should validate email format', async () => {
const user = userEvent.setup()
render(<LoginForm onSubmit={vi.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByText(/invalid email/i)).toBeInTheDocument()
})
it('should disable submit button while submitting', async () => {
const user = userEvent.setup()
const onSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
render(<LoginForm onSubmit={onSubmit} />)
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled()
await waitFor(() => {
expect(screen.getByRole('button', { name: /sign in/i })).toBeEnabled()
})
@@ -346,7 +346,7 @@ describe('StatusBadge', () => {
['info', 'bg-blue-500'],
])('should apply correct class for %s status', (status, expectedClass) => {
render(<StatusBadge status={status} />)
expect(screen.getByTestId('status-badge')).toHaveClass(expectedClass)
})
@@ -357,7 +357,7 @@ describe('StatusBadge', () => {
{ input: 'invalid', expected: 'Unknown' },
])('should show "Unknown" for invalid input: $input', ({ input, expected }) => {
render(<StatusBadge status={input} />)
expect(screen.getByText(expected)).toBeInTheDocument()
})
})
@@ -53,18 +53,18 @@ describe('NodeConfigPanel', () => {
it('should render node type selector', () => {
const node = createMockNode({ type: 'llm' })
render(<NodeConfigPanel node={node} />)
expect(screen.getByLabelText(/model/i)).toBeInTheDocument()
})
it('should update node config on change', async () => {
const user = userEvent.setup()
const node = createMockNode({ type: 'llm' })
render(<NodeConfigPanel node={node} />)
await user.selectOptions(screen.getByLabelText(/model/i), 'gpt-4')
expect(mockWorkflowStore.updateNode).toHaveBeenCalledWith(
node.id,
expect.objectContaining({ model: 'gpt-4' })
@@ -76,14 +76,14 @@ describe('NodeConfigPanel', () => {
it('should show error for invalid input', async () => {
const user = userEvent.setup()
const node = createMockNode({ type: 'code' })
render(<NodeConfigPanel node={node} />)
// Enter invalid code
const codeInput = screen.getByLabelText(/code/i)
await user.clear(codeInput)
await user.type(codeInput, 'invalid syntax {{{')
await waitFor(() => {
expect(screen.getByText(/syntax error/i)).toBeInTheDocument()
})
@@ -91,11 +91,11 @@ describe('NodeConfigPanel', () => {
it('should validate required fields', async () => {
const node = createMockNode({ type: 'http', data: { url: '' } })
render(<NodeConfigPanel node={node} />)
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(screen.getByText(/url is required/i)).toBeInTheDocument()
})
@@ -113,28 +113,28 @@ describe('NodeConfigPanel', () => {
id: 'node-2',
type: 'llm',
})
mockWorkflowStore.nodes = [upstreamNode, currentNode]
mockWorkflowStore.edges = [{ source: 'node-1', target: 'node-2' }]
render(<NodeConfigPanel node={currentNode} />)
// Variable selector should show upstream variables
fireEvent.click(screen.getByRole('button', { name: /add variable/i }))
expect(screen.getByText('user_input')).toBeInTheDocument()
})
it('should insert variable into prompt template', async () => {
const user = userEvent.setup()
const node = createMockNode({ type: 'llm' })
render(<NodeConfigPanel node={node} />)
// Click variable button
await user.click(screen.getByRole('button', { name: /insert variable/i }))
await user.click(screen.getByText('user_input'))
const promptInput = screen.getByLabelText(/prompt/i)
expect(promptInput).toHaveValue(expect.stringContaining('{{user_input}}'))
})
@@ -179,14 +179,14 @@ describe('DocumentUploader', () => {
const user = userEvent.setup()
const onUpload = vi.fn()
mockedService.uploadDocument.mockResolvedValue({ id: 'doc-1' })
render(<DocumentUploader onUpload={onUpload} />)
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const input = screen.getByLabelText(/upload/i)
await user.upload(input, file)
await waitFor(() => {
expect(mockedService.uploadDocument).toHaveBeenCalledWith(
expect.any(FormData)
@@ -196,35 +196,35 @@ describe('DocumentUploader', () => {
it('should reject invalid file types', async () => {
const user = userEvent.setup()
render(<DocumentUploader />)
const file = new File(['content'], 'test.exe', { type: 'application/x-msdownload' })
const input = screen.getByLabelText(/upload/i)
await user.upload(input, file)
expect(screen.getByText(/unsupported file type/i)).toBeInTheDocument()
expect(mockedService.uploadDocument).not.toHaveBeenCalled()
})
it('should show upload progress', async () => {
const user = userEvent.setup()
// Mock upload with progress
mockedService.uploadDocument.mockImplementation(() => {
return new Promise((resolve) => {
setTimeout(() => resolve({ id: 'doc-1' }), 100)
})
})
render(<DocumentUploader />)
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await user.upload(screen.getByLabelText(/upload/i), file)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
@@ -235,12 +235,12 @@ describe('DocumentUploader', () => {
it('should handle upload failure', async () => {
const user = userEvent.setup()
mockedService.uploadDocument.mockRejectedValue(new Error('Upload failed'))
render(<DocumentUploader />)
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await user.upload(screen.getByLabelText(/upload/i), file)
await waitFor(() => {
expect(screen.getByText(/upload failed/i)).toBeInTheDocument()
})
@@ -251,18 +251,18 @@ describe('DocumentUploader', () => {
mockedService.uploadDocument
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({ id: 'doc-1' })
render(<DocumentUploader />)
const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
await user.upload(screen.getByLabelText(/upload/i), file)
await waitFor(() => {
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /retry/i }))
await waitFor(() => {
expect(screen.getByText(/uploaded successfully/i)).toBeInTheDocument()
})
@@ -283,13 +283,13 @@ describe('DocumentList', () => {
page: 1,
pageSize: 10,
})
render(<DocumentList datasetId="ds-1" />)
await waitFor(() => {
expect(screen.getByText('Doc 1')).toBeInTheDocument()
})
expect(mockedService.getDocuments).toHaveBeenCalledWith('ds-1', { page: 1 })
})
@@ -301,22 +301,22 @@ describe('DocumentList', () => {
page: 1,
pageSize: 10,
})
render(<DocumentList datasetId="ds-1" />)
await waitFor(() => {
expect(screen.getByText('Doc 1')).toBeInTheDocument()
})
mockedService.getDocuments.mockResolvedValue({
data: [{ id: '11', name: 'Doc 11' }],
total: 50,
page: 2,
pageSize: 10,
})
await user.click(screen.getByRole('button', { name: /next/i }))
await waitFor(() => {
expect(screen.getByText('Doc 11')).toBeInTheDocument()
})
@@ -327,21 +327,21 @@ describe('DocumentList', () => {
it('should filter by search query', async () => {
const user = userEvent.setup()
vi.useFakeTimers()
render(<DocumentList datasetId="ds-1" />)
await user.type(screen.getByPlaceholderText(/search/i), 'test query')
// Debounce
vi.advanceTimersByTime(300)
await waitFor(() => {
expect(mockedService.getDocuments).toHaveBeenCalledWith(
'ds-1',
expect.objectContaining({ search: 'test query' })
)
})
vi.useRealTimers()
})
})
@@ -391,50 +391,50 @@ describe('AppConfigForm', () => {
describe('Form Validation', () => {
it('should require app name', async () => {
const user = userEvent.setup()
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
// Clear name field
await user.clear(screen.getByLabelText(/name/i))
await user.click(screen.getByRole('button', { name: /save/i }))
expect(screen.getByText(/name is required/i)).toBeInTheDocument()
expect(mockedService.updateAppConfig).not.toHaveBeenCalled()
})
it('should validate name length', async () => {
const user = userEvent.setup()
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
})
// Enter very long name
await user.clear(screen.getByLabelText(/name/i))
await user.type(screen.getByLabelText(/name/i), 'a'.repeat(101))
expect(screen.getByText(/name must be less than 100 characters/i)).toBeInTheDocument()
})
it('should allow empty optional fields', async () => {
const user = userEvent.setup()
mockedService.updateAppConfig.mockResolvedValue({ success: true })
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
// Leave description empty (optional)
await user.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockedService.updateAppConfig).toHaveBeenCalled()
})
@@ -445,58 +445,58 @@ describe('AppConfigForm', () => {
it('should save configuration', async () => {
const user = userEvent.setup()
mockedService.updateAppConfig.mockResolvedValue({ success: true })
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
await user.clear(screen.getByLabelText(/name/i))
await user.type(screen.getByLabelText(/name/i), 'Updated App')
await user.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockedService.updateAppConfig).toHaveBeenCalledWith(
'app-1',
expect.objectContaining({ name: 'Updated App' })
)
})
expect(screen.getByText(/saved successfully/i)).toBeInTheDocument()
})
it('should reset to default values', async () => {
const user = userEvent.setup()
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
// Make changes
await user.clear(screen.getByLabelText(/name/i))
await user.type(screen.getByLabelText(/name/i), 'Changed Name')
// Reset
await user.click(screen.getByRole('button', { name: /reset/i }))
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
it('should show unsaved changes warning', async () => {
const user = userEvent.setup()
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
// Make changes
await user.type(screen.getByLabelText(/name/i), ' Updated')
expect(screen.getByText(/unsaved changes/i)).toBeInTheDocument()
})
})
@@ -505,15 +505,15 @@ describe('AppConfigForm', () => {
it('should show error on save failure', async () => {
const user = userEvent.setup()
mockedService.updateAppConfig.mockRejectedValue(new Error('Server error'))
render(<AppConfigForm appId="app-1" />)
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('My App')
})
await user.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(screen.getByText(/failed to save/i)).toBeInTheDocument()
})
@@ -54,12 +54,12 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details.
## Mock Placement
| Location | Purpose |
|----------|---------|
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
| Test file | Test-specific mocks, inline with `vi.mock()` |
| Location | Purpose |
| -------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
| Test file | Test-specific mocks, inline with `vi.mock()` |
Modules are not mocked automatically. Use `vi.mock` in test files, or add global mocks in `web/vitest.setup.ts`.
@@ -85,10 +85,12 @@ The global mock provides:
```typescript
import { createReactI18nextMock } from '@/test/i18n-mock'
vi.mock('react-i18next', () => createReactI18nextMock({
'my.custom.key': 'Custom translation',
'button.save': 'Save',
}))
vi.mock('react-i18next', () =>
createReactI18nextMock({
'my.custom.key': 'Custom translation',
'button.save': 'Save',
}),
)
```
**Avoid**: Manually defining `useTranslation` mocks that just return the key - the global mock already does this.
@@ -189,16 +191,16 @@ const mockedApi = vi.mocked(api)
describe('Component', () => {
beforeEach(() => {
vi.clearAllMocks()
// Setup default mock implementation
mockedApi.fetchData.mockResolvedValue({ data: [] })
})
it('should show data on success', async () => {
mockedApi.fetchData.mockResolvedValue({ data: [{ id: 1 }] })
render(<Component />)
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument()
})
@@ -206,9 +208,9 @@ describe('Component', () => {
it('should show error on failure', async () => {
mockedApi.fetchData.mockRejectedValue(new Error('Network error'))
render(<Component />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
@@ -231,9 +233,9 @@ describe('GithubComponent', () => {
headers: { 'Content-Type': 'application/json' },
}),
)
render(<GithubComponent />)
await waitFor(() => {
expect(screen.getByText('dify')).toBeInTheDocument()
})
@@ -246,9 +248,9 @@ describe('GithubComponent', () => {
headers: { 'Content-Type': 'application/json' },
}),
)
render(<GithubComponent />)
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
@@ -267,25 +269,25 @@ import { createMockProviderContextValue, createMockPlan } from '@/__mocks__/prov
describe('Component with Context', () => {
it('should render for free plan', () => {
const mockContext = createMockPlan('sandbox')
render(
<ProviderContext.Provider value={mockContext}>
<Component />
</ProviderContext.Provider>
)
expect(screen.getByText('Upgrade')).toBeInTheDocument()
})
it('should render for pro plan', () => {
const mockContext = createMockPlan('professional')
render(
<ProviderContext.Provider value={mockContext}>
<Component />
</ProviderContext.Provider>
)
expect(screen.queryByText('Upgrade')).not.toBeInTheDocument()
})
})
@@ -411,7 +413,7 @@ Manual mocking conflicts with the global Zustand mock and loses store functional
```typescript
// ❌ WRONG: Don't mock the store module
vi.mock('@/app/components/app/store', () => ({
useStore: (selector) => mockSelector(selector), // Missing getState, setState!
useStore: (selector) => mockSelector(selector), // Missing getState, setState!
}))
// ❌ WRONG: This conflicts with global zustand mock
@@ -439,14 +441,11 @@ const mockStore = {
}
vi.mock('@/app/components/app/store', () => ({
useStore: Object.assign(
(selector: (state: typeof mockStore) => unknown) => selector(mockStore),
{
getState: () => mockStore,
setState: vi.fn(),
subscribe: vi.fn(),
},
),
useStore: Object.assign((selector: (state: typeof mockStore) => unknown) => selector(mockStore), {
getState: () => mockStore,
setState: vi.fn(),
subscribe: vi.fn(),
}),
}))
```
@@ -528,7 +527,7 @@ it('should display project owner', () => {
const project = createMockProject({
owner: createMockUser({ name: 'John Doe' }),
})
render(<ProjectCard project={project} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
@@ -6,10 +6,10 @@ This guide defines the workflow for generating tests, especially for complex com
This guide addresses **multi-file workflow** (how to process multiple test files). For coverage requirements within a single test file, see `web/docs/test.md` § Coverage Goals.
| Scope | Rule |
|-------|------|
| **Single file** | Complete coverage in one generation (100% function, >95% branch) |
| **Multi-file directory** | Process one file at a time, verify each before proceeding |
| Scope | Rule |
| ------------------------ | ---------------------------------------------------------------- |
| **Single file** | Complete coverage in one generation (100% function, >95% branch) |
| **Multi-file directory** | Process one file at a time, verify each before proceeding |
## ⚠️ Critical Rule: Incremental Approach for Multi-File Testing
@@ -17,14 +17,14 @@ When testing a **directory with multiple files**, **NEVER generate all test file
### Why Incremental?
| Batch Approach (❌) | Incremental Approach (✅) |
|---------------------|---------------------------|
| Generate 5+ tests at once | Generate 1 test at a time |
| Run tests only at the end | Run test immediately after each file |
| Multiple failures compound | Single point of failure, easy to debug |
| Hard to identify root cause | Clear cause-effect relationship |
| Mock issues affect many files | Mock issues caught early |
| Messy git history | Clean, atomic commits possible |
| Batch Approach (❌) | Incremental Approach (✅) |
| ----------------------------- | -------------------------------------- |
| Generate 5+ tests at once | Generate 1 test at a time |
| Run tests only at the end | Run test immediately after each file |
| Multiple failures compound | Single point of failure, easy to debug |
| Hard to identify root cause | Clear cause-effect relationship |
| Mock issues affect many files | Mock issues caught early |
| Messy git history | Clean, atomic commits possible |
## Single File Workflow
@@ -190,7 +190,7 @@ Update status as you complete each:
```
# BAD: Writing all files then testing
Write component-a.spec.tsx
Write component-b.spec.tsx
Write component-b.spec.tsx
Write component-c.spec.tsx
Write component-d.spec.tsx
Run pnpm test ← Multiple failures, hard to debug
+40 -46
View File
@@ -1,49 +1,43 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/anaconda
{
"name": "Python 3.12",
"build": {
"context": "..",
"dockerfile": "Dockerfile"
},
"mounts": [
"source=dify-dev-tmp,target=/tmp,type=volume"
],
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
},
"ghcr.io/devcontainers-extra/features/npm-package:1": {
"package": "typescript",
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"azureDnsAutoDetection": true,
"installDockerBuildx": true,
"version": "latest",
"dockerDashComposeVersion": "v2"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-python.pylint",
"GitHub.copilot",
"ms-python.python"
]
}
},
"postStartCommand": "./.devcontainer/post_start_command.sh",
"postCreateCommand": "./.devcontainer/post_create_command.sh"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "python --version",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
}
"name": "Python 3.12",
"build": {
"context": "..",
"dockerfile": "Dockerfile"
},
"mounts": ["source=dify-dev-tmp,target=/tmp,type=volume"],
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
},
"ghcr.io/devcontainers-extra/features/npm-package:1": {
"package": "typescript",
"version": "latest"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"azureDnsAutoDetection": true,
"installDockerBuildx": true,
"version": "latest",
"dockerDashComposeVersion": "v2"
}
},
"customizations": {
"vscode": {
"extensions": ["ms-python.pylint", "GitHub.copilot", "ms-python.python"]
}
},
"postStartCommand": "./.devcontainer/post_start_command.sh",
"postCreateCommand": "./.devcontainer/post_create_command.sh"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "python --version",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
}
+4 -4
View File
@@ -1,17 +1,17 @@
title: "General Discussion"
title: 'General Discussion'
body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
required: true
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
required: true
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: textarea
attributes:
+4 -4
View File
@@ -1,17 +1,17 @@
title: "Help"
title: 'Help'
body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
required: true
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
required: true
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: textarea
attributes:
+3 -3
View File
@@ -3,15 +3,15 @@ body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have searched for existing issues [search for existing issues](https://github.com/langgenius/dify/issues), including closed ones.
required: true
- label: I confirm that I am using English to submit this report (我已阅读并同意 [Language Policy](https://github.com/langgenius/dify/issues/1542)).
required: true
- label: "[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)"
- label: '[FOR CHINESE USERS] 请务必使用英文提交 Issue,否则会被关闭。谢谢!:)'
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: textarea
attributes:
+3 -3
View File
@@ -1,4 +1,4 @@
name: "🕷️ Bug report"
name: '🕷️ Bug report'
description: Report errors or unexpected behavior
labels:
- bug
@@ -6,7 +6,7 @@ body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
required: true
@@ -18,7 +18,7 @@ body:
required: true
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: input
+3 -3
View File
@@ -1,13 +1,13 @@
blank_issues_enabled: false
contact_links:
- name: "\U0001F510 Security Vulnerabilities"
url: "https://github.com/langgenius/dify/security/advisories/new"
url: 'https://github.com/langgenius/dify/security/advisories/new'
about: Report security vulnerabilities through GitHub Security Advisories to ensure responsible disclosure. 💡 Please do not report security vulnerabilities in public issues.
- name: "\U0001F4A1 Model Providers & Plugins"
url: "https://github.com/langgenius/dify-official-plugins/issues/new/choose"
url: 'https://github.com/langgenius/dify-official-plugins/issues/new/choose'
about: Report issues with official plugins or model providers, you will need to provide the plugin version and other relevant details.
- name: "\U0001F4AC Documentation Issues"
url: "https://github.com/langgenius/dify-docs/issues/new"
url: 'https://github.com/langgenius/dify-docs/issues/new'
about: Report issues with the documentation, such as typos, outdated information, or missing content. Please provide the specific section and details of the issue.
- name: "\U0001F4E7 Discussions"
url: https://github.com/langgenius/dify/discussions/categories/general
+3 -3
View File
@@ -1,4 +1,4 @@
name: "⭐ Feature or enhancement request"
name: '⭐ Feature or enhancement request'
description: Propose something new.
labels:
- enhancement
@@ -6,7 +6,7 @@ body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
required: true
@@ -14,7 +14,7 @@ body:
required: true
- label: I confirm that I am using English to submit this report, otherwise it will be closed.
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: textarea
attributes:
+7 -7
View File
@@ -1,11 +1,11 @@
name: "✨ Refactor or Chore"
name: '✨ Refactor or Chore'
description: Refactor existing code or perform maintenance chores to improve readability and reliability.
title: "[Refactor/Chore] "
title: '[Refactor/Chore] '
body:
- type: checkboxes
attributes:
label: Self Checks
description: "To make sure we get to you in time, please check the following :)"
description: 'To make sure we get to you in time, please check the following :)'
options:
- label: I have read the [Contributing Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) and [Language Policy](https://github.com/langgenius/dify/issues/1542).
required: true
@@ -17,26 +17,26 @@ body:
required: true
- label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :)
required: true
- label: "Please do not modify this template :) and fill in all the required fields."
- label: 'Please do not modify this template :) and fill in all the required fields.'
required: true
- type: textarea
id: description
attributes:
label: Description
placeholder: "Describe the refactor or chore you are proposing."
placeholder: 'Describe the refactor or chore you are proposing.'
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
placeholder: "Explain why this refactor or chore is necessary."
placeholder: 'Explain why this refactor or chore is necessary.'
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
placeholder: "Add any other context or screenshots about the request here."
placeholder: 'Add any other context or screenshots about the request here.'
validations:
required: false
+164 -164
View File
@@ -1,223 +1,223 @@
version: 2
updates:
- package-ecosystem: "uv"
directory: "/api"
- package-ecosystem: 'uv'
directory: '/api'
open-pull-requests-limit: 10
schedule:
interval: "weekly"
interval: 'weekly'
groups:
flask:
patterns:
- "flask"
- "flask-*"
- "werkzeug"
- "gunicorn"
- 'flask'
- 'flask-*'
- 'werkzeug'
- 'gunicorn'
google:
patterns:
- "google-*"
- "googleapis-*"
- 'google-*'
- 'googleapis-*'
opentelemetry:
patterns:
- "opentelemetry-*"
- 'opentelemetry-*'
pydantic:
patterns:
- "pydantic"
- "pydantic-*"
- 'pydantic'
- 'pydantic-*'
llm:
patterns:
- "langfuse"
- "langsmith"
- "litellm"
- "mlflow*"
- "opik"
- "weave*"
- "arize*"
- "tiktoken"
- "transformers"
- 'langfuse'
- 'langsmith'
- 'litellm'
- 'mlflow*'
- 'opik'
- 'weave*'
- 'arize*'
- 'tiktoken'
- 'transformers'
database:
patterns:
- "sqlalchemy"
- "psycopg2*"
- "psycogreen"
- "redis*"
- "alembic*"
- 'sqlalchemy'
- 'psycopg2*'
- 'psycogreen'
- 'redis*'
- 'alembic*'
storage:
patterns:
- "boto3*"
- "botocore*"
- "azure-*"
- "bce-*"
- "cos-python-*"
- "esdk-obs-*"
- "google-cloud-storage"
- "opendal"
- "oss2"
- "supabase*"
- "tos*"
- 'boto3*'
- 'botocore*'
- 'azure-*'
- 'bce-*'
- 'cos-python-*'
- 'esdk-obs-*'
- 'google-cloud-storage'
- 'opendal'
- 'oss2'
- 'supabase*'
- 'tos*'
vdb:
patterns:
- "alibabacloud*"
- "chromadb"
- "clickhouse-*"
- "clickzetta-*"
- "couchbase"
- "elasticsearch"
- "opensearch-py"
- "oracledb"
- "pgvect*"
- "pymilvus"
- "pymochow"
- "pyobvector"
- "qdrant-client"
- "intersystems-*"
- "tablestore"
- "tcvectordb"
- "tidb-vector"
- "upstash-*"
- "volcengine-*"
- "weaviate-*"
- "xinference-*"
- "mo-vector"
- "mysql-connector-*"
- 'alibabacloud*'
- 'chromadb'
- 'clickhouse-*'
- 'clickzetta-*'
- 'couchbase'
- 'elasticsearch'
- 'opensearch-py'
- 'oracledb'
- 'pgvect*'
- 'pymilvus'
- 'pymochow'
- 'pyobvector'
- 'qdrant-client'
- 'intersystems-*'
- 'tablestore'
- 'tcvectordb'
- 'tidb-vector'
- 'upstash-*'
- 'volcengine-*'
- 'weaviate-*'
- 'xinference-*'
- 'mo-vector'
- 'mysql-connector-*'
dev:
patterns:
- "coverage"
- "dotenv-linter"
- "faker"
- "lxml-stubs"
- "basedpyright"
- "ruff"
- "pytest*"
- "types-*"
- "boto3-stubs"
- "hypothesis"
- "pandas-stubs"
- "scipy-stubs"
- "import-linter"
- "celery-types"
- "mypy*"
- "pyrefly"
- 'coverage'
- 'dotenv-linter'
- 'faker'
- 'lxml-stubs'
- 'basedpyright'
- 'ruff'
- 'pytest*'
- 'types-*'
- 'boto3-stubs'
- 'hypothesis'
- 'pandas-stubs'
- 'scipy-stubs'
- 'import-linter'
- 'celery-types'
- 'mypy*'
- 'pyrefly'
python-packages:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
- '*'
- package-ecosystem: 'github-actions'
directory: '/'
open-pull-requests-limit: 5
schedule:
interval: "weekly"
interval: 'weekly'
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "uv"
directory: "/api"
target-branch: "lts/1.13.x"
- '*'
- package-ecosystem: 'uv'
directory: '/api'
target-branch: 'lts/1.13.x'
open-pull-requests-limit: 10
schedule:
interval: "weekly"
interval: 'weekly'
groups:
flask:
patterns:
- "flask"
- "flask-*"
- "werkzeug"
- "gunicorn"
- 'flask'
- 'flask-*'
- 'werkzeug'
- 'gunicorn'
google:
patterns:
- "google-*"
- "googleapis-*"
- 'google-*'
- 'googleapis-*'
opentelemetry:
patterns:
- "opentelemetry-*"
- 'opentelemetry-*'
pydantic:
patterns:
- "pydantic"
- "pydantic-*"
- 'pydantic'
- 'pydantic-*'
llm:
patterns:
- "langfuse"
- "langsmith"
- "litellm"
- "mlflow*"
- "opik"
- "weave*"
- "arize*"
- "tiktoken"
- "transformers"
- 'langfuse'
- 'langsmith'
- 'litellm'
- 'mlflow*'
- 'opik'
- 'weave*'
- 'arize*'
- 'tiktoken'
- 'transformers'
database:
patterns:
- "sqlalchemy"
- "psycopg2*"
- "psycogreen"
- "redis*"
- "alembic*"
- 'sqlalchemy'
- 'psycopg2*'
- 'psycogreen'
- 'redis*'
- 'alembic*'
storage:
patterns:
- "boto3*"
- "botocore*"
- "azure-*"
- "bce-*"
- "cos-python-*"
- "esdk-obs-*"
- "google-cloud-storage"
- "opendal"
- "oss2"
- "supabase*"
- "tos*"
- 'boto3*'
- 'botocore*'
- 'azure-*'
- 'bce-*'
- 'cos-python-*'
- 'esdk-obs-*'
- 'google-cloud-storage'
- 'opendal'
- 'oss2'
- 'supabase*'
- 'tos*'
vdb:
patterns:
- "alibabacloud*"
- "chromadb"
- "clickhouse-*"
- "clickzetta-*"
- "couchbase"
- "elasticsearch"
- "opensearch-py"
- "oracledb"
- "pgvect*"
- "pymilvus"
- "pymochow"
- "pyobvector"
- "qdrant-client"
- "intersystems-*"
- "tablestore"
- "tcvectordb"
- "tidb-vector"
- "upstash-*"
- "volcengine-*"
- "weaviate-*"
- "xinference-*"
- "mo-vector"
- "mysql-connector-*"
- 'alibabacloud*'
- 'chromadb'
- 'clickhouse-*'
- 'clickzetta-*'
- 'couchbase'
- 'elasticsearch'
- 'opensearch-py'
- 'oracledb'
- 'pgvect*'
- 'pymilvus'
- 'pymochow'
- 'pyobvector'
- 'qdrant-client'
- 'intersystems-*'
- 'tablestore'
- 'tcvectordb'
- 'tidb-vector'
- 'upstash-*'
- 'volcengine-*'
- 'weaviate-*'
- 'xinference-*'
- 'mo-vector'
- 'mysql-connector-*'
dev:
patterns:
- "coverage"
- "dotenv-linter"
- "faker"
- "lxml-stubs"
- "basedpyright"
- "ruff"
- "pytest*"
- "types-*"
- "boto3-stubs"
- "hypothesis"
- "pandas-stubs"
- "scipy-stubs"
- "import-linter"
- "celery-types"
- "mypy*"
- "pyrefly"
- 'coverage'
- 'dotenv-linter'
- 'faker'
- 'lxml-stubs'
- 'basedpyright'
- 'ruff'
- 'pytest*'
- 'types-*'
- 'boto3-stubs'
- 'hypothesis'
- 'pandas-stubs'
- 'scipy-stubs'
- 'import-linter'
- 'celery-types'
- 'mypy*'
- 'pyrefly'
python-packages:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
target-branch: "lts/1.13.x"
- '*'
- package-ecosystem: 'github-actions'
directory: '/'
target-branch: 'lts/1.13.x'
open-pull-requests-limit: 5
schedule:
interval: "weekly"
interval: 'weekly'
groups:
github-actions-dependencies:
patterns:
- "*"
- '*'
+1 -1
View File
@@ -1 +1 @@
failure-threshold: "error"
failure-threshold: 'error'
-1
View File
@@ -1,5 +1,4 @@
---
extends: default
rules:
+20 -20
View File
@@ -1,22 +1,22 @@
{
"Verbose": false,
"Debug": false,
"IgnoreDefaults": false,
"SpacesAfterTabs": false,
"NoColor": false,
"Exclude": [
"^web/public/vs/",
"^web/public/pdf.worker.min.mjs$",
"web/app/components/base/icons/src/vender/"
],
"AllowedContentTypes": [],
"PassedFiles": [],
"Disable": {
"EndOfLine": false,
"Indentation": false,
"IndentSize": true,
"InsertFinalNewline": false,
"TrimTrailingWhitespace": false,
"MaxLineLength": false
}
"Verbose": false,
"Debug": false,
"IgnoreDefaults": false,
"SpacesAfterTabs": false,
"NoColor": false,
"Exclude": [
"^web/public/vs/",
"^web/public/pdf.worker.min.mjs$",
"web/app/components/base/icons/src/vender/"
],
"AllowedContentTypes": [],
"PassedFiles": [],
"Disable": {
"EndOfLine": false,
"Indentation": false,
"IndentSize": true,
"InsertFinalNewline": false,
"TrimTrailingWhitespace": false,
"MaxLineLength": false
}
}
+2 -2
View File
@@ -12,8 +12,8 @@
## Screenshots
| Before | After |
|--------|-------|
| ... | ... |
| ------ | ----- |
| ... | ... |
## Checklist
+11 -12
View File
@@ -8,31 +8,31 @@ const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const englishPath = (fileStem) => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
if (!fs.existsSync(filePath)) return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
if (!baseSha) return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], {
encoding: 'utf8',
})
return JSON.parse(content)
}
catch {
} catch {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const compareJson = (beforeValue, afterValue) =>
JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
@@ -59,8 +59,7 @@ for (const fileStem of files) {
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
if (!(key in afterJson)) deleted.push(key)
}
changes[fileStem] = {
@@ -78,5 +77,5 @@ fs.writeFileSync(
headSha,
files,
changes,
})
}),
)
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.12"
- '3.12'
steps:
- name: Checkout code
@@ -87,7 +87,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.12"
- '3.12'
steps:
- name: Checkout code
@@ -151,7 +151,7 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install dependencies
+16 -4
View File
@@ -1,12 +1,12 @@
name: autofix.ci
on:
pull_request:
branches: ["main"]
branches: ['main']
merge_group:
branches: ["main"]
branches: ['main']
types: [checks_requested]
push:
branches: ["main"]
branches: ['main']
permissions:
contents: read
@@ -44,6 +44,12 @@ jobs:
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
eslint.config.mjs
web/eslint.config.mjs
.vscode/**
.github/workflows/autofix.yml
.github/workflows/style.yml
- name: Check api inputs
if: github.event_name != 'merge_group'
id: api-changes
@@ -63,7 +69,7 @@ jobs:
- if: github.event_name != 'merge_group'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
python-version: '3.11'
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
@@ -146,6 +152,12 @@ jobs:
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
- name: Format web files
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
env:
CHANGED_FILES: ${{ steps.web-changes.outputs.all_changed_files }}
run: vp fmt --no-error-on-unmatched-pattern $CHANGED_FILES
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: |
+39 -39
View File
@@ -3,14 +3,14 @@ name: Build and Push API & Web
on:
push:
branches:
- "main"
- "deploy/**"
- "build/**"
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- 'main'
- 'deploy/**'
- 'build/**'
- 'release/e-*'
- 'hotfix/**'
- 'feat/hitl-backend'
tags:
- "*"
- '*'
concurrency:
group: build-push-${{ github.head_ref || github.run_id }}
@@ -32,32 +32,32 @@ jobs:
strategy:
matrix:
include:
- service_name: "build-api-amd64"
image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: 'build-api-amd64'
image_name_env: 'DIFY_API_IMAGE_NAME'
artifact_context: 'api'
build_context: '{{defaultContext}}'
file: 'api/Dockerfile'
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-api-arm64"
image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: 'build-api-arm64'
image_name_env: 'DIFY_API_IMAGE_NAME'
artifact_context: 'api'
build_context: '{{defaultContext}}'
file: 'api/Dockerfile'
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-web-amd64"
image_name_env: "DIFY_WEB_IMAGE_NAME"
artifact_context: "web"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: 'build-web-amd64'
image_name_env: 'DIFY_WEB_IMAGE_NAME'
artifact_context: 'web'
build_context: '{{defaultContext}}'
file: 'web/Dockerfile'
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-web-arm64"
image_name_env: "DIFY_WEB_IMAGE_NAME"
artifact_context: "web"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: 'build-web-arm64'
image_name_env: 'DIFY_WEB_IMAGE_NAME'
artifact_context: 'web'
build_context: '{{defaultContext}}'
file: 'web/Dockerfile'
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
@@ -116,12 +116,12 @@ jobs:
strategy:
matrix:
include:
- service_name: "validate-api-amd64"
build_context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "validate-web-amd64"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: 'validate-api-amd64'
build_context: '{{defaultContext}}'
file: 'api/Dockerfile'
- service_name: 'validate-web-amd64'
build_context: '{{defaultContext}}'
file: 'web/Dockerfile'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
@@ -141,12 +141,12 @@ jobs:
strategy:
matrix:
include:
- service_name: "merge-api-images"
image_name_env: "DIFY_API_IMAGE_NAME"
context: "api"
- service_name: "merge-web-images"
image_name_env: "DIFY_WEB_IMAGE_NAME"
context: "web"
- service_name: 'merge-api-images'
image_name_env: 'DIFY_API_IMAGE_NAME'
context: 'api'
- service_name: 'merge-web-images'
image_name_env: 'DIFY_WEB_IMAGE_NAME'
context: 'web'
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+108 -109
View File
@@ -4,19 +4,19 @@ on:
workflow_dispatch:
inputs:
cli_ref:
description: "Git ref (default: current branch)"
description: 'Git ref (default: current branch)'
type: string
required: false
edition:
description: "Dify edition"
description: 'Dify edition'
type: choice
required: false
default: ee
options: [ee, ce]
test_scope:
description: "smoke = [P0] only / full = all cases"
description: 'smoke = [P0] only / full = all cases'
type: choice
required: false
default: full
@@ -24,23 +24,23 @@ on:
# ── Suite on/off ────────────────────────────────────────────────────────
suite_framework_output_error:
description: "framework + output + error-handling suites"
description: 'framework + output + error-handling suites'
type: boolean
default: true
suite_discovery:
description: "discovery suite (get app / describe app)"
description: 'discovery suite (get app / describe app)'
type: boolean
default: true
suite_run:
description: "run suite (basic / streaming / conversation / file / hitl)"
description: 'run suite (basic / streaming / conversation / file / hitl)'
type: boolean
default: true
suite_auth:
description: "auth suite (login / status / whoami / use / devices / logout)"
description: 'auth suite (login / status / whoami / use / devices / logout)'
type: boolean
default: true
suite_agent:
description: "agent suite"
description: 'agent suite'
type: boolean
default: true
@@ -51,32 +51,31 @@ permissions:
# Each job reads DIFY_E2E_TOKEN + app IDs from the provision job outputs,
# so global-setup skips minting and finds existing apps in < 10 s.
env:
DIFY_E2E_NO_KEYRING: "1" # Linux CI has no keychain; skip probe
VITEST_RETRY: "2" # Retry flaky staging responses
DIFY_E2E_NO_KEYRING: '1' # Linux CI has no keychain; skip probe
VITEST_RETRY: '2' # Retry flaky staging responses
jobs:
# ════════════════════════════════════════════════════════════════════════════
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
# ════════════════════════════════════════════════════════════════════════════
provision:
name: "Provision: mint token + DSL apps"
name: 'Provision: mint token + DSL apps'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
@@ -101,18 +100,18 @@ jobs:
id: out
working-directory: cli
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
run: bun scripts/e2e-provision.ts
# ════════════════════════════════════════════════════════════════════════════
# 1-B. framework + output + error-handling (parallel with run/discovery)
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 1-B. framework + output + error-handling (parallel with run/discovery)
# ════════════════════════════════════════════════════════════════════════════
suite-framework-output-error:
name: "Suite: framework + output + error-handling"
name: 'Suite: framework + output + error-handling'
if: ${{ inputs.suite_framework_output_error != 'false' }}
needs: provision
runs-on: ubuntu-latest
@@ -138,16 +137,16 @@ jobs:
- name: Run framework + output + error-handling
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts"
DIFY_E2E_INCLUDE: 'test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts'
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
@@ -155,11 +154,11 @@ jobs:
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 1-C. Discovery (parallel)
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 1-C. Discovery (parallel)
# ════════════════════════════════════════════════════════════════════════════
suite-discovery:
name: "Suite: discovery"
name: 'Suite: discovery'
if: ${{ inputs.suite_discovery != 'false' }}
needs: provision
runs-on: ubuntu-latest
@@ -185,17 +184,17 @@ jobs:
- name: Run discovery suite
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/discovery/**/*.e2e.ts"
DIFY_E2E_INCLUDE: 'test/e2e/suites/discovery/**/*.e2e.ts'
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
@@ -203,11 +202,11 @@ jobs:
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 1-D. Run suite — 5 files in matrix (parallel)
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 1-D. Run suite — 5 files in matrix (parallel)
# ════════════════════════════════════════════════════════════════════════════
suite-run:
name: "Suite: run / ${{ matrix.name }}"
name: 'Suite: run / ${{ matrix.name }}'
if: ${{ inputs.suite_run != 'false' }}
needs: provision
runs-on: ubuntu-latest
@@ -246,25 +245,25 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
- name: "Run run/${{ matrix.name }}"
- name: 'Run run/${{ matrix.name }}'
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/run/${{ matrix.file }}"
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
DIFY_E2E_INCLUDE: 'test/e2e/suites/run/${{ matrix.file }}'
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
@@ -280,11 +279,11 @@ jobs:
path: cli/test-results/
retention-days: 3
# ════════════════════════════════════════════════════════════════════════════
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
# ════════════════════════════════════════════════════════════════════════════
suite-auth-safe:
name: "Suite: auth (login / status / whoami)"
name: 'Suite: auth (login / status / whoami)'
if: ${{ inputs.suite_auth != 'false' }}
needs: provision
runs-on: ubuntu-latest
@@ -310,15 +309,15 @@ jobs:
- name: Run auth/login + status + whoami
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_INCLUDE: "test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts"
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_INCLUDE: 'test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts'
run: |
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
pnpm test:e2e -- -t "\[P0\]"
@@ -326,13 +325,13 @@ jobs:
pnpm test:e2e
fi
# ════════════════════════════════════════════════════════════════════════════
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
# Must wait for ALL parallel suites to finish to avoid token revocation
# invalidating other in-flight requests.
# ════════════════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════════════════
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
# Must wait for ALL parallel suites to finish to avoid token revocation
# invalidating other in-flight requests.
# ════════════════════════════════════════════════════════════════════════════
suite-last:
name: "Suite: auth-use + devices + logout + agent (last, serial)"
name: 'Suite: auth-use + devices + logout + agent (last, serial)'
# Runs when auth is selected; also runs after all parallel jobs finish
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
needs:
@@ -366,20 +365,20 @@ jobs:
- name: Run use / devices / logout / agent (serial)
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
run: |
# Collect files in safe order: use → devices → logout (revokes last) → agent
FILES=()
+2 -2
View File
@@ -4,11 +4,11 @@ on:
workflow_dispatch:
inputs:
dify_version:
description: "Dify image tag to test against (e.g. 1.7.0)"
description: 'Dify image tag to test against (e.g. 1.7.0)'
type: string
required: true
cli_ref:
description: "Git ref to build the cli from (default: current branch)"
description: 'Git ref to build the cli from (default: current branch)'
type: string
required: false
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install dependencies
@@ -72,7 +72,7 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install dependencies
+2 -2
View File
@@ -2,9 +2,9 @@ name: Deploy Dev
on:
workflow_run:
workflows: ["Build and Push API & Web"]
workflows: ['Build and Push API & Web']
branches:
- "deploy/dev"
- 'deploy/dev'
types:
- completed
+2 -2
View File
@@ -5,9 +5,9 @@ permissions:
on:
workflow_run:
workflows: ["Build and Push API & Web"]
workflows: ['Build and Push API & Web']
branches:
- "deploy/enterprise"
- 'deploy/enterprise'
types:
- completed
+2 -2
View File
@@ -2,9 +2,9 @@ name: Deploy HITL
on:
workflow_run:
workflows: ["Build and Push API & Web"]
workflows: ['Build and Push API & Web']
branches:
- "build/feat/hitl"
- 'build/feat/hitl'
types:
- completed
+2 -2
View File
@@ -5,9 +5,9 @@ permissions:
on:
workflow_run:
workflows: ["Build and Push API & Web"]
workflows: ['Build and Push API & Web']
branches:
- "deploy/saas"
- 'deploy/saas'
types:
- completed
+19 -19
View File
@@ -3,7 +3,7 @@ name: Build docker image
on:
pull_request:
branches:
- "main"
- 'main'
paths:
- api/Dockerfile
- api/Dockerfile.dockerignore
@@ -28,26 +28,26 @@ jobs:
strategy:
matrix:
include:
- service_name: "api-amd64"
- service_name: 'api-amd64'
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "api-arm64"
context: '{{defaultContext}}'
file: 'api/Dockerfile'
- service_name: 'api-arm64'
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "web-amd64"
context: '{{defaultContext}}'
file: 'api/Dockerfile'
- service_name: 'web-amd64'
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: "web-arm64"
context: '{{defaultContext}}'
file: 'web/Dockerfile'
- service_name: 'web-arm64'
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
context: "{{defaultContext}}"
file: "web/Dockerfile"
context: '{{defaultContext}}'
file: 'web/Dockerfile'
steps:
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
@@ -69,12 +69,12 @@ jobs:
strategy:
matrix:
include:
- service_name: "api-amd64"
context: "{{defaultContext}}"
file: "api/Dockerfile"
- service_name: "web-amd64"
context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: 'api-amd64'
context: '{{defaultContext}}'
file: 'api/Dockerfile'
- service_name: 'web-amd64'
context: '{{defaultContext}}'
file: 'web/Dockerfile'
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+1 -1
View File
@@ -1,4 +1,4 @@
name: "Pull Request Labeler"
name: 'Pull Request Labeler'
on:
pull_request_target:
+3 -3
View File
@@ -2,12 +2,12 @@ name: Main CI Pipeline
on:
pull_request:
branches: ["main"]
branches: ['main']
merge_group:
branches: ["main"]
branches: ['main']
types: [checks_requested]
push:
branches: ["main"]
branches: ['main']
permissions:
actions: write
+1 -1
View File
@@ -8,7 +8,7 @@ on:
- reopened
- synchronize
merge_group:
branches: ["main"]
branches: ['main']
types: [checks_requested]
jobs:
+2 -3
View File
@@ -11,7 +11,6 @@ on:
jobs:
stale:
runs-on: depot-ubuntu-24.04
permissions:
issues: write
@@ -23,8 +22,8 @@ jobs:
days-before-issue-stale: 15
days-before-issue-close: 3
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it."
stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it."
stale-issue-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
stale-pr-message: 'Closed due to inactivity. If you have any questions, you can reopen it.'
stale-issue-label: 'no-issue-activity'
stale-pr-label: 'no-pr-activity'
any-of-labels: '🌚 invalid,🙋‍♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted'
+10 -1
View File
@@ -36,7 +36,7 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: false
python-version: "3.12"
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install dependencies
@@ -132,7 +132,10 @@ jobs:
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
eslint.config.mjs
.vscode/**
.github/workflows/autofix.yml
.github/workflows/style.yml
.github/actions/setup-web/**
@@ -140,6 +143,12 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Format check
if: steps.changed-files.outputs.any_changed == 'true'
env:
CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
run: vp fmt --check --no-error-on-unmatched-pattern $CHANGED_FILES
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
+10 -10
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.12"
- '3.12'
steps:
- name: Checkout code
@@ -48,16 +48,16 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
# compose-file: docker/tidb/docker-compose.yaml
# services: |
# tidb
# tiflash
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
# compose-file: docker/tidb/docker-compose.yaml
# services: |
# tidb
# tiflash
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores
run: |
+10 -10
View File
@@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
python-version:
- "3.12"
- '3.12'
steps:
- name: Checkout code
@@ -45,16 +45,16 @@ jobs:
- name: Install dependencies
run: uv sync --project api --dev
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
# compose-file: docker/tidb/docker-compose.yaml
# services: |
# tidb
# tiflash
# - name: Set up Vector Store (TiDB)
# uses: hoverkraft-tech/compose-action@v2.0.2
# with:
# compose-file: docker/tidb/docker-compose.yaml
# services: |
# tidb
# tiflash
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
# - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
- name: Test Vector Stores
run: |
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
@@ -47,7 +47,7 @@ jobs:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: "1"
E2E_FORCE_WEB_BUILD: '1'
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
+9 -23
View File
@@ -1,31 +1,17 @@
{
"cucumber.features": [
"e2e/features/**/*.feature",
],
"cucumber.glue": [
"e2e/features/**/*.ts",
],
"cucumber.features": ["e2e/features/**/*.feature"],
"cucumber.glue": ["e2e/features/**/*.ts"],
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
},
// Format
"editor.formatOnSave": true,
"oxc.fmt.configPath": "./vite.config.ts",
// Silent the stylistic rules in your IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Lint fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
// Enable eslint for all supported languages
"eslint.validate": [
+1 -1
View File
@@ -31,7 +31,7 @@ The codebase is split into:
## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
- **TypeScript**: Use the strict config, rely on `vp fmt` for formatting, ESLint for lint-only fixes, plus `pnpm type-check`, and avoid `any` types.
## General Practices
+10 -10
View File
@@ -34,11 +34,11 @@ Don't forget to link an existing issue or open a new issue in the PR's descripti
How we prioritize:
| Issue Type | Priority |
| ------------------------------------------------------------ | --------------- |
| Bugs in core functions (cloud service, cannot login, applications not working, security loopholes) | Critical |
| Non-critical bugs, performance boosts | Medium Priority |
| Minor fixes (typos, confusing but working UI) | Low Priority |
| Issue Type | Priority |
| -------------------------------------------------------------------------------------------------- | --------------- |
| Bugs in core functions (cloud service, cannot login, applications not working, security loopholes) | Critical |
| Non-critical bugs, performance boosts | Medium Priority |
| Minor fixes (typos, confusing but working UI) | Low Priority |
### Feature requests
@@ -52,12 +52,12 @@ How we prioritize:
How we prioritize:
| Feature Type | Priority |
| ------------------------------------------------------------ | --------------- |
| High-Priority Features as being labeled by a team member | High Priority |
| Feature Type | Priority |
| --------------------------------------------------------------------------------------------------------------------------------- | --------------- |
| High-Priority Features as being labeled by a team member | High Priority |
| Popular feature requests from our [community feedback board](https://github.com/langgenius/dify/discussions/categories/feedbacks) | Medium Priority |
| Non-core features and minor enhancements | Low Priority |
| Valuable but not immediate | Future-Feature |
| Non-core features and minor enhancements | Low Priority |
| Valuable but not immediate | Future-Feature |
## Submitting your PR
+28 -12
View File
@@ -80,7 +80,14 @@ export default class MyCommand extends DifyCommand {
// Authed: authedCtx() sets outputFormat + builds context
const ctx = await this.authedCtx({ format: flags.output })
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
process.stdout.write(
await runMyThing(
{
/* args */
},
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
),
)
}
}
```
@@ -102,7 +109,7 @@ import { ErrorCode } from '../../errors/codes.js'
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'workspace id required',
hint: 'pass --workspace or run \'difyctl use workspace <id>\'',
hint: "pass --workspace or run 'difyctl use workspace <id>'",
})
```
@@ -150,10 +157,7 @@ export type IOStreams = {
`runWithSpinner` wraps async call with animated spinner on stderr. Auto-disables for structured output — no manual `enabled:` flag needed.
```typescript
const result = await runWithSpinner(
{ io, label: 'Fetching apps' },
() => client.list(params),
)
const result = await runWithSpinner({ io, label: 'Fetching apps' }, () => client.list(params))
```
`STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name'])` drives disable check. New structured format = add to this set only — no other callsites change.
@@ -174,9 +178,15 @@ Output rendering separated from data fetching via protocol objects.
```typescript
// handlers.ts — implement the protocol on the data object
export class MyListOutput implements TablePrintable {
tableColumns() { return COLUMNS }
tableRows() { return this.rows.map(r => r.tableRow()) }
json() { return { items: this.rows.map(r => r.json()) } }
tableColumns() {
return COLUMNS
}
tableRows() {
return this.rows.map((r) => r.tableRow())
}
json() {
return { items: this.rows.map((r) => r.json()) }
}
}
// index.ts — wrap and return
@@ -215,10 +225,16 @@ One file per resource under `src/api/`. Each exports class wrapping `KyInstance`
```typescript
export class AppsClient {
private readonly http: KyInstance
constructor(http: KyInstance) { this.http = http }
constructor(http: KyInstance) {
this.http = http
}
async list(params: ListParams): Promise<ListResponse> { /* ... */ throw new Error('elided') }
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> { /* ... */ throw new Error('elided') }
async list(params: ListParams): Promise<ListResponse> {
/* ... */ throw new Error('elided')
}
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> {
/* ... */ throw new Error('elided')
}
}
```
+29 -24
View File
@@ -6,13 +6,8 @@ import markdownPreferences from 'eslint-plugin-markdown-preferences'
export default antfu(
{
ignores: original => [
'context/**',
'docs/**',
'dist/**',
'coverage/**',
...original,
],
stylistic: false,
ignores: (original) => ['context/**', 'docs/**', 'dist/**', 'coverage/**', ...original],
typescript: {
overrides: {
'ts/consistent-type-definitions': ['error', 'type'],
@@ -26,11 +21,6 @@ export default antfu(
'test/prefer-lowercase-title': 'off',
},
},
stylistic: {
overrides: {
'antfu/top-level-function': 'off',
},
},
e18e: false,
},
markdownPreferences.configs.standard,
@@ -45,10 +35,7 @@ export default antfu(
minLinks: 1,
},
],
'markdown-preferences/ordered-list-marker-sequence': [
'error',
{ increment: 'never' },
],
'markdown-preferences/ordered-list-marker-sequence': ['error', { increment: 'never' }],
'markdown-preferences/definitions-last': 'error',
'markdown-preferences/sort-definitions': 'error',
},
@@ -56,6 +43,18 @@ export default antfu(
{
rules: {
'node/prefer-global/process': 'off',
'unicorn/number-literal-case': 'off',
'unused-imports/no-unused-vars': [
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
caughtErrors: 'none',
ignoreRestSiblings: true,
vars: 'all',
varsIgnorePattern: '^_',
},
],
},
},
{
@@ -67,14 +66,20 @@ export default antfu(
{
files: ['src/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['../**', './*/**', '..'],
message: 'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
},
],
}],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['../**', './*/**', '..'],
message:
'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
},
],
},
],
},
},
)
.remove('antfu/sort/package-json')
.remove('antfu/sort/tsconfig-json')
+48 -28
View File
@@ -1,41 +1,19 @@
{
"name": "@langgenius/difyctl",
"type": "module",
"version": "0.1.0-rc.1",
"description": "Dify command-line interface",
"difyctl": {
"channel": "rc",
"compat": {
"minDify": "1.14.0",
"maxDify": "1.15.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{ "id": "linux-x64", "bunTarget": "bun-linux-x64", "exe": false },
{ "id": "linux-arm64", "bunTarget": "bun-linux-arm64", "exe": false },
{ "id": "darwin-x64", "bunTarget": "bun-darwin-x64", "exe": false },
{ "id": "darwin-arm64", "bunTarget": "bun-darwin-arm64", "exe": false },
{ "id": "windows-x64", "bunTarget": "bun-windows-x64", "exe": true }
]
}
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"README.md",
"bin",
"dist"
],
"engines": {
"node": "^22.22.1"
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vp pack",
@@ -89,5 +67,47 @@
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": "^22.22.1"
},
"difyctl": {
"channel": "rc",
"compat": {
"minDify": "1.14.0",
"maxDify": "1.15.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
]
}
}
}
+80 -67
View File
@@ -36,7 +36,7 @@ import { fileURLToPath } from 'node:url'
const host = process.env.DIFY_E2E_HOST ?? ''
const email = process.env.DIFY_E2E_EMAIL ?? ''
const password = process.env.DIFY_E2E_PASSWORD ?? ''
const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce'
const edition = (process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase() as 'ee' | 'ce'
const preToken = process.env.DIFY_E2E_TOKEN ?? ''
if (!host || !email || !password) {
@@ -49,10 +49,10 @@ const base = host.replace(/\/$/, '')
// ── Helpers ───────────────────────────────────────────────────────────────────
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
return new Promise((r) => setTimeout(r, ms))
}
async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> {
async function consoleLogin(): Promise<{ cookieString: string; csrfToken: string }> {
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const res = await fetch(`${base}/console/api/login`, {
method: 'POST',
@@ -60,14 +60,15 @@ async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(15_000),
})
if (!res.ok)
throw new Error(`console/api/login failed: HTTP ${res.status}`)
if (!res.ok) throw new Error(`console/api/login failed: HTTP ${res.status}`)
const setCookies = res.headers.getSetCookie?.() ?? []
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ')
// cookie names may have __Host- prefix on HTTPS deployments
const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token='))
const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : ''
const csrfPair = setCookies.map((c) => c.split(';')[0]).find((p) => p.includes('csrf_token='))
const csrfToken = csrfPair
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
: ''
return { cookieString, csrfToken }
}
@@ -78,8 +79,9 @@ async function validateToken(token: string): Promise<boolean> {
signal: AbortSignal.timeout(10_000),
})
return res.ok
} catch {
return false
}
catch { return false }
}
async function mintToken(cookieStr: string, csrf: string, label: string): Promise<string> {
@@ -90,28 +92,27 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
signal: AbortSignal.timeout(15_000),
})
if (!codeRes.ok)
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = (await codeRes.json()) as {
device_code: string
user_code: string
}
// Step 2: approve (with retry)
let approveRes: Response | undefined
for (let i = 1; i <= 5; i++) {
approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf },
headers: { 'Content-Type': 'application/json', Cookie: cookieStr, 'X-CSRFToken': csrf },
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (approveRes.ok)
break
if (approveRes.status !== 429 && approveRes.status < 500)
break
if (approveRes.ok) break
if (approveRes.status !== 429 && approveRes.status < 500) break
console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`)
await sleep(i * 2_000)
}
if (!approveRes?.ok)
throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
if (!approveRes?.ok) throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
// Step 3: exchange token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
@@ -120,39 +121,42 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = await tokenRes.json() as { token?: string }
if (!body.token)
throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = (await tokenRes.json()) as { token?: string }
if (!body.token) throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
return body.token
}
async function discoverWorkspaces(cookieStr: string, csrf: string) {
const res = await fetch(`${base}/console/api/workspaces`, {
headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf },
headers: { Cookie: cookieStr, 'X-CSRF-Token': csrf },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok)
throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> }
if (!res.ok) throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = (await res.json()) as { workspaces?: Array<{ id: string; name: string }> }
const all = data.workspaces ?? []
if (edition === 'ee') {
const ws0 = all.find(w => w.name === 'auto_test0')
const ws1 = all.find(w => w.name === 'auto_test1')
if (!ws0)
throw new Error('[provision] EE: workspace "auto_test0" not found')
const ws0 = all.find((w) => w.name === 'auto_test0')
const ws1 = all.find((w) => w.name === 'auto_test1')
if (!ws0) throw new Error('[provision] EE: workspace "auto_test0" not found')
console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`)
console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`)
console.warn(
`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`,
)
return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id }
}
const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name))
const auto = all
.filter((w) => w.name.toLowerCase().includes('auto'))
.sort((a, b) => a.name.localeCompare(b.name))
const primary = auto[0] ?? all[0]
if (!primary)
throw new Error('[provision] No workspaces found')
return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id }
if (!primary) throw new Error('[provision] No workspaces found')
return {
primaryWsId: primary.id,
primaryWsName: primary.name,
secondaryWsId: auto[1]?.id ?? primary.id,
}
}
async function provisionApps(
@@ -162,7 +166,7 @@ async function provisionApps(
secondaryWsId: string,
): Promise<Record<string, string>> {
const mkH = (extra: Record<string, string> = {}) => ({
'Cookie': cookieStr,
Cookie: cookieStr,
'X-CSRF-Token': csrf,
...extra,
})
@@ -202,9 +206,10 @@ async function provisionApps(
}
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appName =
(dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? ''
// Find existing or import
@@ -212,34 +217,34 @@ async function provisionApps(
`${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`,
{ headers: mkH(), signal: AbortSignal.timeout(10_000) },
)
const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> }
let appId = searchData.data?.find(a => a.name === appName)?.id
const searchData = (await searchRes.json()) as { data?: Array<{ id: string; name: string }> }
let appId = searchData.data?.find((a) => a.name === appName)?.id
if (appId) {
console.warn(`[provision] ${dslFile}: exists id=${appId}`)
}
else {
} else {
const importRes = await fetch(`${base}/console/api/apps/imports`, {
method: 'POST',
headers: { ...mkH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }),
signal: AbortSignal.timeout(30_000),
})
const importData = await importRes.json() as { app_id?: string, import_id?: string }
const importData = (await importRes.json()) as { app_id?: string; import_id?: string }
if (importRes.status === 202 && importData.import_id) {
const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, {
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
})
const confirmData = await confirmRes.json() as { app_id?: string }
const confirmRes = await fetch(
`${base}/console/api/apps/imports/${importData.import_id}/confirm`,
{
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
},
)
const confirmData = (await confirmRes.json()) as { app_id?: string }
appId = confirmData.app_id
}
else {
} else {
appId = importData.app_id
}
if (!appId)
throw new Error(`import failed: ${JSON.stringify(importData)}`)
if (!appId) throw new Error(`import failed: ${JSON.stringify(importData)}`)
console.warn(`[provision] ${dslFile}: imported id=${appId}`)
}
@@ -270,8 +275,7 @@ async function provisionApps(
}
results[envVar] = appId
}
catch (err) {
} catch (err) {
console.warn(`[provision] ${dslFile} skipped: ${err}`)
}
}
@@ -281,7 +285,9 @@ async function provisionApps(
async function writeOutputs(outputs: Record<string, string>) {
const ghOutput = process.env.GITHUB_OUTPUT
const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
const lines = `${Object.entries(outputs)
.map(([k, v]) => `${k}=${v}`)
.join('\n')}\n`
// Always write local JSON for debugging
const { writeFile } = await import('node:fs/promises')
@@ -310,18 +316,19 @@ async function main() {
// 2. Token
let primaryToken = preToken
if (primaryToken && await validateToken(primaryToken)) {
if (primaryToken && (await validateToken(primaryToken))) {
console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}`)
}
else {
if (primaryToken)
console.warn('[provision] Pre-set token invalid, minting fresh…')
} else {
if (primaryToken) console.warn('[provision] Pre-set token invalid, minting fresh…')
primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision')
console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}`)
}
// 3. Discover workspaces
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken)
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(
cookieString,
csrfToken,
)
// 4. Provision apps
const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId)
@@ -333,10 +340,16 @@ async function main() {
// their describe calls rejected with "workspace_id does not match app's workspace".
await fetch(`${base}/console/api/workspaces/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
headers: {
'Content-Type': 'application/json',
Cookie: cookieString,
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ tenant_id: primaryWsId }),
signal: AbortSignal.timeout(10_000),
}).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`))
}).catch((err: unknown) =>
console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`),
)
console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`)
// 5. Write outputs
+57 -24
View File
@@ -14,18 +14,22 @@ import {
describe('pathToTokens', () => {
it('extracts tokens for nested command', () => {
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
.toEqual(['auth', 'devices', 'list'])
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')).toEqual([
'auth',
'devices',
'list',
])
})
it('extracts tokens for top-level command', () => {
expect(pathToTokens('src/commands/version/index.ts', 'src/commands'))
.toEqual(['version'])
expect(pathToTokens('src/commands/version/index.ts', 'src/commands')).toEqual(['version'])
})
it('normalizes backslashes (windows-style paths)', () => {
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands'))
.toEqual(['auth', 'login'])
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')).toEqual([
'auth',
'login',
])
})
})
@@ -49,22 +53,35 @@ describe('tokensToIdentifier', () => {
describe('buildTree', () => {
it('assembles a nested tree from entries', () => {
const entries = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
]
const tree = buildTree(entries)
expect(tree.subcommands.get('auth')?.command).toBeUndefined()
expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin')
expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command)
.toBe('AuthDevicesList')
expect(
tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command,
).toBe('AuthDevicesList')
expect(tree.subcommands.get('version')?.command).toBe('Version')
})
it('supports a parent command with its own children', () => {
const entries = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
@@ -76,9 +93,17 @@ describe('buildTree', () => {
describe('formatModule', () => {
it('produces a deterministic ESM file with imports + tree literal', () => {
const entries: CommandEntry[] = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@@ -111,7 +136,11 @@ export const commandTree: CommandTree = {
it('emits parent-with-own-command shape', () => {
const entries: CommandEntry[] = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@@ -130,7 +159,11 @@ export const commandTree: CommandTree = {
it('imports sorted alphabetically by import path', () => {
const entries: CommandEntry[] = [
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
]
const out = formatModule(entries, buildTree(entries))
const authIdx = out.indexOf('AuthLogin')
@@ -145,7 +178,10 @@ function makeFixture(): string {
mkdirSync(join(commands, 'auth', 'login'), { recursive: true })
writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n')
mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true })
writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n')
writeFileSync(
join(commands, 'auth', 'devices', 'list', 'index.ts'),
'export default class DevicesList {}\n',
)
mkdirSync(join(commands, '_shared'), { recursive: true })
writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n')
mkdirSync(join(commands, 'version'), { recursive: true })
@@ -157,12 +193,12 @@ describe('discoverCommands', () => {
it('returns sorted entries, skipping _-prefixed segments', async () => {
const root = makeFixture()
const entries = await discoverCommands(join(root, 'src', 'commands'))
expect(entries.map(e => e.tokens.join('/'))).toEqual([
expect(entries.map((e) => e.tokens.join('/'))).toEqual([
'auth/devices/list',
'auth/login',
'version',
])
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
expect(entries.find((e) => e.tokens[0] === '_shared')).toBeUndefined()
})
it('errors on a loose .ts file under commands/', async () => {
@@ -194,8 +230,7 @@ describe('generate', () => {
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(true)
})
@@ -205,10 +240,8 @@ describe('generate', () => {
await generate({ commandsDir, mode: 'write' })
writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n')
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.diff).toBeDefined()
if (!result.ok) expect(result.diff).toBeDefined()
})
})
+40 -65
View File
@@ -57,26 +57,22 @@ export type TreeNode = {
export function pathToTokens(filePath: string, commandsRoot: string): string[] {
const normalized = filePath.replace(/\\/g, '/')
const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '')
const trimmed = normalized.startsWith(`${root}/`)
? normalized.slice(root.length + 1)
: normalized
const trimmed = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized
const withoutIndex = trimmed.replace(/\/index\.ts$/, '')
return withoutIndex.split('/').filter(s => s.length > 0)
return withoutIndex.split('/').filter((s) => s.length > 0)
}
function capitalize(part: string): string {
if (part.length === 0)
return ''
if (part.length === 0) return ''
return part[0]!.toUpperCase() + part.slice(1)
}
export function tokensToIdentifier(tokens: readonly string[]): string {
const id = tokens
.flatMap(t => t.split(/[-_]/))
.flatMap((t) => t.split(/[-_]/))
.map(capitalize)
.join('')
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase()))
return `_${id}`
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) return `_${id}`
return id
}
@@ -103,18 +99,15 @@ const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDI
`
function compareStrings(a: string, b: string): number {
if (a < b)
return -1
if (a > b)
return 1
if (a < b) return -1
if (a > b) return 1
return 0
}
function emitImports(entries: readonly CommandEntry[]): string {
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
const lines = [`import type { CommandTree } from '@/framework/registry'`]
for (const e of sorted)
lines.push(`import ${e.identifier} from '${e.importPath}'`)
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
return lines.join('\n')
}
@@ -123,13 +116,11 @@ function emitNode(node: TreeNode, indent: string): string {
const keys = [...node.subcommands.keys()].sort()
const parts: string[] = []
if (node.command !== undefined)
parts.push(`${inner}command: ${node.command},`)
if (node.command !== undefined) parts.push(`${inner}command: ${node.command},`)
if (keys.length === 0) {
parts.push(`${inner}subcommands: {},`)
}
else {
} else {
parts.push(`${inner}subcommands: {`)
for (const key of keys) {
const child = node.subcommands.get(key)!
@@ -143,14 +134,9 @@ function emitNode(node: TreeNode, indent: string): string {
function emitEntry(key: string, node: TreeNode, indent: string): string {
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
if (isLeaf) return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${key}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')
return [`${indent}${key}: {`, emitNode(node, indent), `${indent}},`].join('\n')
}
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
@@ -170,10 +156,8 @@ async function walk(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory())
out.push(...await walk(full))
else if (e.isFile())
out.push(full)
if (e.isDirectory()) out.push(...(await walk(full)))
else if (e.isFile()) out.push(full)
}
return out
}
@@ -184,21 +168,20 @@ function toPosix(p: string): string {
export async function discoverCommands(commandsDir: string): Promise<CommandEntry[]> {
const all = await walk(commandsDir)
const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
const tsFiles = all.filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'),
)
const loose: string[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (rel === 'tree.ts' || rel === 'tree.generated.ts')
continue
if (isExcludedCommandPath(rel)) continue
if (rel === 'tree.ts' || rel === 'tree.generated.ts') continue
// Only flag files directly under commands/ (no path separator — no parent folder)
if (!rel.includes('/'))
loose.push(rel)
if (!rel.includes('/')) loose.push(rel)
}
if (loose.length > 0) {
const list = loose.map(p => ` - src/commands/${p}`).join('\n')
const list = loose.map((p) => ` - src/commands/${p}`).join('\n')
throw new Error(
`commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`,
)
@@ -207,15 +190,11 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
const entries: CommandEntry[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (!rel.endsWith('/index.ts'))
continue
if (isExcludedCommandPath(rel)) continue
if (!rel.endsWith('/index.ts')) continue
const tokens = pathToTokens(rel, '')
if (tokens.length === 0)
continue
if (tokens[0]!.startsWith('-'))
throw new Error(`command token cannot start with '-': ${rel}`)
if (tokens.length === 0) continue
if (tokens[0]!.startsWith('-')) throw new Error(`command token cannot start with '-': ${rel}`)
entries.push({
tokens,
identifier: tokensToIdentifier(tokens),
@@ -225,8 +204,7 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
if (entries.length === 0)
throw new Error(`no commands found under ${commandsDir}`)
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
assertUniqueIdentifiers(entries)
return entries
@@ -247,10 +225,10 @@ export type GenerateOptions = {
readonly mode: 'write' | 'check'
}
export type GenerateResult
= | { mode: 'write', wrote: boolean, path: string }
| { mode: 'check', ok: true, path: string }
| { mode: 'check', ok: false, path: string, diff: string }
export type GenerateResult =
| { mode: 'write'; wrote: boolean; path: string }
| { mode: 'check'; ok: true; path: string }
| { mode: 'check'; ok: false; path: string; diff: string }
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
const entries = await discoverCommands(opts.commandsDir)
@@ -262,12 +240,10 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
let onDisk = ''
try {
onDisk = await readFile(target, 'utf8')
}
catch {
} catch {
onDisk = ''
}
if (onDisk === content)
return { mode: 'check', ok: true, path: target }
if (onDisk === content) return { mode: 'check', ok: true, path: target }
return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) }
}
@@ -284,10 +260,8 @@ function shortDiff(a: string, b: string): string {
const max = Math.max(aLines.length, bLines.length)
for (let i = 0; i < max; i++) {
if (aLines[i] !== bLines[i]) {
if (aLines[i] !== undefined)
lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined)
lines.push(`+ ${bLines[i]}`)
if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`)
}
}
return lines.slice(0, 40).join('\n')
@@ -307,12 +281,13 @@ async function main(): Promise<void> {
process.stderr.write(`tree:check ok\n`)
return
}
process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`)
process.stderr.write(
`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`,
)
process.exit(1)
}
const invokedDirectly = process.argv[1] !== undefined
&& fileURLToPath(import.meta.url) === process.argv[1]
const invokedDirectly =
process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
if (invokedDirectly)
await main()
if (invokedDirectly) await main()
+29 -7
View File
@@ -41,18 +41,35 @@ const FETCH_STUB = [
].join('\n')
/* eslint-enable no-template-curly-in-string */
function runLib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
function runLib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] })
const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
})
const LIST_NEWEST_FIRST = JSON.stringify({
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
})
const RELEASE = JSON.stringify({
tag_name: '1.14.2',
@@ -117,7 +134,10 @@ describe('install-cli asset_version', () => {
describe('install-cli resolve_release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { DIFY_VERSION: '1.14.2', TAG_1_14_2: REL_1142 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
DIFY_VERSION: '1.14.2',
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@@ -129,7 +149,9 @@ describe('install-cli resolve_release', () => {
})
it('blank resolves to the latest stable release', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { LATEST_JSON: REL_1150 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
LATEST_JSON: REL_1150,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.15.0')
})
+75 -39
View File
@@ -5,9 +5,13 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
function hasPwsh(): boolean {
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], {
encoding: 'utf8',
})
const r = spawnSync(
'pwsh',
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
{
encoding: 'utf8',
},
)
return r.status === 0
}
@@ -16,26 +20,26 @@ const PWSH = hasPwsh()
const STUB = [
'function Invoke-RestMethod {',
' param([string]$Uri, $Headers)',
' if ($Uri -like \'*/releases/latest\') {',
' if (-not $env:HX_LATEST) { throw \'mock 404\' }',
" if ($Uri -like '*/releases/latest') {",
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
' return ($env:HX_LATEST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases?per_page=100\') {',
' if (-not $env:HX_LIST) { throw \'mock 404\' }',
" elseif ($Uri -like '*/releases?per_page=100') {",
" if (-not $env:HX_LIST) { throw 'mock 404' }",
' return ($env:HX_LIST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases/tags/*\') {',
' $t = $Uri -replace \'.*/releases/tags/\', \'\'',
' $k = \'HX_TAG_\' + ($t -replace \'[.\\-]\', \'_\')',
" elseif ($Uri -like '*/releases/tags/*') {",
" $t = $Uri -replace '.*/releases/tags/', ''",
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
' $v = [Environment]::GetEnvironmentVariable($k)',
' if (-not $v) { throw \'mock 404\' }',
" if (-not $v) { throw 'mock 404' }",
' return ($v | ConvertFrom-Json)',
' }',
' throw "unexpected uri $Uri"',
'}',
].join('\n')
type Run = { code: number, stdout: string, stderr: string }
type Run = { code: number; stdout: string; stderr: string }
function runPwsh(body: string, env: Record<string, string> = {}): Run {
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
@@ -54,8 +58,14 @@ function runPwsh(body: string, env: Record<string, string> = {}): Run {
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
})
const LIST_NEWEST_FIRST = JSON.stringify([
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
@@ -63,25 +73,31 @@ const LIST_NEWEST_FIRST = JSON.stringify([
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
it('extracts the version from a windows .exe asset name', () => {
const r = runPwsh('(Get-AssetSemver \'difyctl-v0.2.0-windows-x64.exe\').Version')
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('extracts a prerelease version and its rc number', () => {
const r = runPwsh('$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"')
const r = runPwsh(
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.1.0-rc.1 1')
})
it('rejects a non-windows asset (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-v0.2.0-linux-x64\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('rejects a malformed core version (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-vx.y.z-windows-x64.exe\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -89,33 +105,39 @@ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('picks the highest semver among several windows builds', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.10.0')
})
it('prefers the stable release over an rc of the same core', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('ignores checksums and non-windows assets', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
@@ -123,7 +145,9 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('yields null when no windows asset is present', () => {
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
const r = runPwsh(`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`)
const r = runPwsh(
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -131,7 +155,10 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '1.14.2', HX_TAG_1_14_2: REL_1142 })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFY_VERSION: '1.14.2',
HX_TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@@ -155,13 +182,19 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
})
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '0.2.0', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '0.2.0',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '9.9.9', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '9.9.9',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
})
@@ -169,13 +202,16 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
it('returns the newest release whose assets host the wanted build', () => {
const r = runPwsh('(Find-ReleaseForDifyctl \'0.2.0\').tag_name', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('returns nothing when no release hosts the wanted build', () => {
const r = runPwsh('$x = Find-ReleaseForDifyctl \'9.9.9\'; if ($null -eq $x) { \'NULL\' } else { $x.tag_name }', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh(
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
{ HX_LIST: LIST_NEWEST_FIRST },
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
+6 -16
View File
@@ -27,8 +27,7 @@ const GIT_PROBE_OPTS: ExecSyncOptions = {
export const defaultGitProbe: GitProbe = (cmd) => {
try {
return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null
}
catch {
} catch {
return null
}
}
@@ -36,7 +35,7 @@ export const defaultGitProbe: GitProbe = (cmd) => {
type PackageManifest = {
difyctl?: {
channel?: string
compat?: { minDify?: string, maxDify?: string }
compat?: { minDify?: string; maxDify?: string }
}
}
@@ -48,8 +47,7 @@ const defaultPackageReader: PackageReader = () => {
try {
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
}
catch {
} catch {
return {}
}
}
@@ -69,20 +67,12 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
const channel = env.DIFYCTL_CHANNEL ?? pkg.difyctl?.channel ?? 'dev'
if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) {
throw new Error(
`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`,
)
throw new Error(`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`)
}
const version
= env.DIFYCTL_VERSION
?? git('git describe --tags --dirty --always')
?? '0.0.0-dev'
const version = env.DIFYCTL_VERSION ?? git('git describe --tags --dirty --always') ?? '0.0.0-dev'
const commit
= env.DIFYCTL_COMMIT
?? git('git rev-parse HEAD')
?? 'none'
const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none'
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
+4 -4
View File
@@ -2,8 +2,8 @@ import { resolveBuildInfo } from './lib/resolve-buildinfo.js'
const info = resolveBuildInfo()
process.stdout.write(
`version: ${info.version}\n`
+ `commit: ${info.commit}\n`
+ `built: ${info.buildDate}\n`
+ `channel: ${info.channel}\n`,
`version: ${info.version}\n` +
`commit: ${info.commit}\n` +
`built: ${info.buildDate}\n` +
`channel: ${info.channel}\n`,
)
+48 -50
View File
@@ -32,8 +32,8 @@ const CHANNELS = [
{ name: 'rc', prerelease: true, versionForm: /^\d+\.\d+\.\d+-rc\.\d+$/ },
]
const channelByName = name => CHANNELS.find(c => c.name === name)
const channelNames = () => CHANNELS.map(c => c.name).join(', ')
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
function parsePrecedence(v) {
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
@@ -48,21 +48,16 @@ function comparePre(a, b) {
const bparts = b.split('.')
const len = Math.max(aparts.length, bparts.length)
for (let i = 0; i < len; i++) {
if (aparts[i] === undefined)
return -1
if (bparts[i] === undefined)
return 1
if (aparts[i] === undefined) return -1
if (bparts[i] === undefined) return 1
const an = /^\d+$/.test(aparts[i])
const bn = /^\d+$/.test(bparts[i])
if (an && bn) {
const d = Number(aparts[i]) - Number(bparts[i])
if (d !== 0)
return d < 0 ? -1 : 1
}
else if (an !== bn) {
if (d !== 0) return d < 0 ? -1 : 1
} else if (an !== bn) {
return an ? -1 : 1
}
else if (aparts[i] !== bparts[i]) {
} else if (aparts[i] !== bparts[i]) {
return aparts[i] < bparts[i] ? -1 : 1
}
}
@@ -75,15 +70,11 @@ function comparePrecedence(a, b) {
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
const x = A.nums[i] ?? 0
const y = B.nums[i] ?? 0
if (x !== y)
return x < y ? -1 : 1
if (x !== y) return x < y ? -1 : 1
}
if (A.pre === B.pre)
return 0
if (A.pre === '')
return 1
if (B.pre === '')
return -1
if (A.pre === B.pre) return 0
if (A.pre === '') return 1
if (B.pre === '') return -1
return comparePre(A.pre, B.pre)
}
@@ -95,8 +86,7 @@ function die(msg) {
function loadPkg() {
const pkgUrl = new URL('../package.json', import.meta.url)
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
if (!pkg.difyctl?.release)
die('cli/package.json missing difyctl.release')
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
return {
version: pkg.version,
channel: pkg.difyctl.channel,
@@ -119,32 +109,29 @@ function githubEnv() {
tagPrefix: release.tagPrefix,
difyctlTag: `${release.tagPrefix}${version}`,
}
return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n')
return Object.entries(fields)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function requireVersion(version) {
if (!version)
die('version argument is required')
if (!version) die('version argument is required')
return version
}
function assetName(release, version, id) {
const target = release.targets.find(t => t.id === id)
if (!target)
die(`unknown target id: ${id}`)
const target = release.targets.find((t) => t.id === id)
if (!target) die(`unknown target id: ${id}`)
const suffix = target.exe ? '.exe' : ''
return `${release.tagPrefix}${version}-${id}${suffix}`
}
function validateRelease(release) {
const problems = []
const str = v => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix))
problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName))
problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix))
problems.push('checksumsSuffix must be a non-empty string')
const str = (v) => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName)) problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
if (!Array.isArray(release.targets) || release.targets.length === 0) {
problems.push('targets must be a non-empty array')
return problems
@@ -152,15 +139,12 @@ function validateRelease(release) {
const seen = new Set()
for (const t of release.targets) {
const label = t?.id ?? JSON.stringify(t)
if (!str(t?.id))
problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id))
problems.push(`duplicate target id: ${t.id}`)
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
else seen.add(t.id)
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
if (typeof t?.exe !== 'boolean')
problems.push(`target ${label}: exe must be a boolean`)
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
}
@@ -176,9 +160,13 @@ function validateVersionChannel(version, channel) {
return ['package.json version must be a non-empty string']
const ch = channelByName(channel)
if (!ch)
return [`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`]
return [
`difyctl.channel ${JSON.stringify(channel)} is not a known channel (expected one of: ${channelNames()})`,
]
if (!ch.versionForm.test(version))
problems.push(`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`)
problems.push(
`version "${version}" does not match the ${channel} channel form ${ch.versionForm}; an installer could not resolve it`,
)
return problems
}
@@ -188,7 +176,11 @@ function main(argv) {
case 'tag':
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
case 'asset':
return assetName(loadPkg().release, requireVersion(rest[0]), rest[1] ?? die('target id is required'))
return assetName(
loadPkg().release,
requireVersion(rest[0]),
rest[1] ?? die('target id is required'),
)
case 'checksums': {
const { release } = loadPkg()
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
@@ -196,9 +188,11 @@ function main(argv) {
case 'tag-prefix':
return loadPkg().release.tagPrefix
case 'targets':
return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n')
return loadPkg()
.release.targets.map((t) => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`)
.join('\n')
case 'channels':
return CHANNELS.map(c => c.name).join('\n')
return CHANNELS.map((c) => c.name).join('\n')
case 'github-env':
return githubEnv()
case 'compat-check': {
@@ -206,14 +200,18 @@ function main(argv) {
const difyVersion = requireVersion(rest[0])
if (!compat.minDify || !compat.maxDify)
die('cli/package.json missing difyctl.compat.minDify/maxDify')
if (comparePrecedence(difyVersion, compat.minDify) < 0 || comparePrecedence(difyVersion, compat.maxDify) > 0)
die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`)
if (
comparePrecedence(difyVersion, compat.minDify) < 0 ||
comparePrecedence(difyVersion, compat.maxDify) > 0
)
die(
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
)
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
}
case 'prerelease': {
const ch = channelByName(rest[0] ?? die('channel argument is required'))
if (!ch)
die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
return String(ch.prerelease)
}
case 'validate': {
+3 -4
View File
@@ -4,13 +4,12 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
function run(args: string[]): { code: number, stdout: string, stderr: string } {
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
return { code: 0, stdout, stderr: '' }
}
catch (e) {
const err = e as { status?: number, stdout?: string, stderr?: string }
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
+26 -13
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env -S bun
import { execSync } from 'node:child_process'
type Check = { name: string, run: () => void }
type Check = { name: string; run: () => void }
const baseUrlIdx = process.argv.indexOf('--base-url')
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
@@ -17,16 +17,30 @@ function cli(args: string): string {
}
const checks: Check[] = [
{ name: 'config show', run: () => { cli('config show') } },
{ name: 'get workspace', run: () => {
if (!cli('get workspace').includes('id'))
throw new Error('no workspace listed')
} },
{ name: 'get apps', run: () => { cli('get apps') } },
{ name: 'difyctl version prints compat', run: () => {
if (!cli('version').includes('compat:'))
throw new Error('no compat line')
} },
{
name: 'config show',
run: () => {
cli('config show')
},
},
{
name: 'get workspace',
run: () => {
if (!cli('get workspace').includes('id')) throw new Error('no workspace listed')
},
},
{
name: 'get apps',
run: () => {
cli('get apps')
},
},
{
name: 'difyctl version prints compat',
run: () => {
if (!cli('version').includes('compat:')) throw new Error('no compat line')
},
},
]
let failed = 0
@@ -34,8 +48,7 @@ for (const c of checks) {
try {
c.run()
console.log(`[x] ${c.name}`)
}
catch (err) {
} catch (err) {
failed++
console.log(`[ ] ${c.name}${(err as Error).message}`)
}
+9 -8
View File
@@ -19,7 +19,7 @@ describe('AccountSessionsClient.list', () => {
})
it('GETs account/sessions with no query when paging is unset', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list()
@@ -29,7 +29,7 @@ describe('AccountSessionsClient.list', () => {
})
it('forwards page/limit when supplied', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ page: 2, limit: 25 })
@@ -50,7 +50,7 @@ describe('AccountSessionsClient.revoke', () => {
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
// typed client still parses the body — this guards against a regression where
// a non-empty 200 body trips JSON handling.
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')
@@ -58,7 +58,7 @@ describe('AccountSessionsClient.revoke', () => {
})
it('URL-encodes the session id', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await makeClient(stub.url).revoke('sess/1 2')
@@ -66,16 +66,17 @@ describe('AccountSessionsClient.revoke', () => {
})
it('propagates 404 as a classified BaseError', async () => {
stub = await startStubServer(cap =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
stub = await startStubServer((cap) =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
)
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
it('revokeSelf DELETEs the self subresource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')
+1 -1
View File
@@ -10,7 +10,7 @@ export class AccountSessionsClient {
this.orpc = createOpenApiClient(http)
}
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
async list(q?: { page?: number; limit?: number }): Promise<SessionListResponse> {
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
}
+12 -7
View File
@@ -17,11 +17,16 @@ describe('AccountClient.get', () => {
})
it('GETs account, sends the bearer, and returns the parsed payload', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, {
subject_type: 'account',
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
}, cap))
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
subject_type: 'account',
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
},
cap,
),
)
const res = await makeClient(stub.url).get()
@@ -32,10 +37,10 @@ describe('AccountClient.get', () => {
})
it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 401,
(err) => isHttpClientError(err) && err.httpStatus === 401,
)
})
})
+21 -11
View File
@@ -21,7 +21,7 @@ describe('AppDslClient.exportDsl', () => {
})
it('returns the data string from the response', async () => {
stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { data: DSL_YAML }, cap))
const yaml = await makeClient(stub.url).exportDsl('app-1')
@@ -31,16 +31,18 @@ describe('AppDslClient.exportDsl', () => {
})
it('throws when response has no data field', async () => {
stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { wrong: 1 }, cap))
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow(
'export response missing data field',
)
})
it('propagates 404 as a classified HttpClientError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not_found' }, cap))
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
@@ -53,7 +55,7 @@ describe('AppDslClient.importApp', () => {
})
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
@@ -67,10 +69,18 @@ describe('AppDslClient.importApp', () => {
})
it('returns pending import on 202', async () => {
const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
stub = await startStubServer(cap => jsonResponder(202, pending, cap))
const pending = {
id: 'imp-1',
status: 'pending',
current_dsl_version: '0.1.4',
imported_dsl_version: '0.0.9',
}
stub = await startStubServer((cap) => jsonResponder(202, pending, cap))
const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
yaml_content: DSL_YAML,
})
expect(result.status).toBe('pending')
expect(result.id).toBe('imp-1')
@@ -85,7 +95,7 @@ describe('AppDslClient.confirmImport', () => {
})
it('POST to confirm URL and returns completed Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
@@ -103,7 +113,7 @@ describe('AppDslClient.checkDependencies', () => {
})
it('returns empty leaked_dependencies on healthy app', async () => {
stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { leaked_dependencies: [] }, cap))
const result = await makeClient(stub.url).checkDependencies('app-1')
+8 -8
View File
@@ -35,19 +35,19 @@ export class AppDslClient {
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
const resp = await this.orpc.apps.byAppId.export.get({
params: { app_id: appId },
query: query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
query:
query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
})
// The response schema is an open object {"data": "<yaml string>"}; the
// contract generator marks it as loose because the backend annotation
// does not narrow the shape. Extract `data` directly.
const data = (resp as Record<string, unknown>).data
if (typeof data !== 'string')
throw new Error('export response missing data field')
if (typeof data !== 'string') throw new Error('export response missing data field')
return data
}
+19 -7
View File
@@ -23,10 +23,8 @@ describe('AppMetaClient', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
@@ -61,15 +59,29 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const cache = await loadAppInfoCache({
store: getCache(CACHE_APP_INFO),
ttlMs: 100,
now: () => new Date('2026-05-09T00:00:00Z'),
})
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') })
const client = new AppMetaClient({
apps,
host: mock.url,
cache,
now: () => new Date('2026-05-09T00:00:00Z'),
})
await client.get('app-1', [FieldInfo])
expect(spy).toHaveBeenCalledTimes(1)
const client2 = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:01Z') })
const client2 = new AppMetaClient({
apps,
host: mock.url,
cache,
now: () => new Date('2026-05-09T00:00:01Z'),
})
await client2.get('app-1', [FieldInfo])
expect(spy).toHaveBeenCalledTimes(2)
})
+11 -8
View File
@@ -25,21 +25,24 @@ export class AppMetaClient {
async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise<AppMeta> {
const cached = this.cache?.get(this.host, appId)
if (cached !== undefined && this.cache?.isFresh(cached, this.now()) === true && covers(cached.meta, fields))
if (
cached !== undefined &&
this.cache?.isFresh(cached, this.now()) === true &&
covers(cached.meta, fields)
)
return cached.meta
const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields)
const fresh = fromDescribe(resp, fields)
const merged = cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
? mergeMeta(cached.meta, fresh)
: fresh
if (this.cache !== undefined)
await this.cache.set(this.host, appId, merged)
const merged =
cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
? mergeMeta(cached.meta, fresh)
: fresh
if (this.cache !== undefined) await this.cache.set(this.host, appId, merged)
return merged
}
async invalidate(appId: string): Promise<void> {
if (this.cache !== undefined)
await this.cache.delete(this.host, appId)
if (this.cache !== undefined) await this.cache.delete(this.host, appId)
}
}
+17 -10
View File
@@ -75,21 +75,24 @@ describe('AppRunClient.runStream', () => {
it('throws typed BaseError on non-2xx open', async () => {
mock.setScenario('server-5xx')
const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }))
await expect(
c.runStream('app-1', buildRunBody({ message: 'hi' })),
).rejects.toMatchObject({ code: 'server_5xx' })
await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({
code: 'server_5xx',
})
})
it('aborts when signal fires', async () => {
expect.assertions(1)
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
const ctrl = new AbortController()
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { signal: ctrl.signal })
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), {
signal: ctrl.signal,
})
ctrl.abort()
try {
for await (const _ of iter) { /* drain */ }
}
catch (e) {
for await (const _ of iter) {
/* drain */
}
} catch (e) {
expect((e as Error).name).toBe('AbortError')
}
})
@@ -98,9 +101,13 @@ describe('AppRunClient.runStream', () => {
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } }))
const names: string[] = []
for await (const ev of iter)
names.push(ev.name)
expect(names).toEqual(['workflow_started', 'node_started', 'node_finished', 'workflow_finished'])
for await (const ev of iter) names.push(ev.name)
expect(names).toEqual([
'workflow_started',
'node_started',
'node_finished',
'workflow_finished',
])
})
})
+5 -10
View File
@@ -18,16 +18,13 @@ export function buildRunBody(args: RunBodyArgs): Record<string, unknown> {
const body: Record<string, unknown> = {
inputs: args.inputs ?? {},
}
if (args.message !== undefined && args.message !== '')
body.query = args.message
if (args.message !== undefined && args.message !== '') body.query = args.message
if (args.conversationId !== undefined && args.conversationId !== '')
body.conversation_id = args.conversationId
if (args.workspaceId !== undefined && args.workspaceId !== '')
body.workspace_id = args.workspaceId
if (args.workflowId !== undefined && args.workflowId !== '')
body.workflow_id = args.workflowId
if (args.files !== undefined && args.files.length > 0)
body.files = args.files
if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId
if (args.files !== undefined && args.files.length > 0) body.files = args.files
return body
}
@@ -62,8 +59,7 @@ export class AppRunClient {
throwOnError: true,
retryOnRateLimit: opts.retryOnRateLimit,
})
if (res.body === null)
throw new Error('streaming response body missing')
if (res.body === null) throw new Error('streaming response body missing')
return normalizeDifyStream(parseSSE(res.body, opts.signal))
}
@@ -100,8 +96,7 @@ export class AppRunClient {
signal: opts.signal,
throwOnError: true,
})
if (res.body === null)
throw new Error('reconnect stream body missing')
if (res.body === null) throw new Error('reconnect stream body missing')
return normalizeDifyStream(parseSSE(res.body, opts.signal))
}
}
+11 -9
View File
@@ -6,7 +6,9 @@ import { isHttpClientError } from '@/errors/base'
import { AppsClient } from './apps.js'
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
const DESCRIBE_BODY = { info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true } }
const DESCRIBE_BODY = {
info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true },
}
function makeClient(host: string): AppsClient {
return new AppsClient(testHttpClient(host, 'dfoa_test'))
@@ -24,7 +26,7 @@ describe('AppsClient.list', () => {
})
it('defaults page=1 & limit=20 and always sends workspace_id', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1' })
@@ -40,7 +42,7 @@ describe('AppsClient.list', () => {
})
it('forwards explicit pagination and filters', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({
workspaceId: 'ws-1',
@@ -60,7 +62,7 @@ describe('AppsClient.list', () => {
})
it('treats empty-string filters as absent (not blank query params)', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
@@ -71,10 +73,10 @@ describe('AppsClient.list', () => {
})
it('propagates server 403 as a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 403,
(err) => isHttpClientError(err) && err.httpStatus === 403,
)
})
})
@@ -87,7 +89,7 @@ describe('AppsClient.describe', () => {
})
it('hits /apps/<id>/describe, omits workspace_id and fields when not given', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
const res = await makeClient(stub.url).describe('app-1')
@@ -99,7 +101,7 @@ describe('AppsClient.describe', () => {
})
it('joins fields with commas', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema'])
@@ -107,7 +109,7 @@ describe('AppsClient.describe', () => {
})
it('URL-encodes the app id', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
await makeClient(stub.url).describe('app/with space')
+5 -1
View File
@@ -1,4 +1,8 @@
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
import type {
AppDescribeResponse,
AppListResponse,
AppMode,
} from '@dify/contracts/api/openapi/types.gen'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
+21 -20
View File
@@ -15,24 +15,33 @@ type StubServer = {
stop: () => Promise<void>
}
function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<StubServer> {
function startStub(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
): Promise<StubServer> {
return new Promise((resolve, reject) => {
const server = http.createServer(handler)
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo
resolve({
url: `http://127.0.0.1:${addr.port}`,
stop: () => new Promise<void>((res, rej) => server.close(err => err ? rej(err) : res())),
stop: () =>
new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
})
})
server.on('error', reject)
})
}
function jsonStub(status: number, body: unknown): (req: http.IncomingMessage, res: http.ServerResponse) => void {
function jsonStub(
status: number,
body: unknown,
): (req: http.IncomingMessage, res: http.ServerResponse) => void {
return (_req, res) => {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload),
})
res.end(payload)
}
}
@@ -74,15 +83,12 @@ describe('DeviceFlowApi.requestCode', () => {
let caught: unknown
try {
await api.requestCode({ device_label: 'l' })
}
catch (e) {
} catch (e) {
caught = e
}
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
}
finally {
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
} finally {
await stub?.stop()
}
})
@@ -108,8 +114,7 @@ describe('DeviceFlowApi.pollOnce', () => {
const api = makeApi(mock)
const r = await api.pollOnce({ device_code: 'devcode-1' })
expect(r.status).toBe('approved')
if (r.status === 'approved')
expect(r.success.token).toBe('dfoa_test')
if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test')
})
it('maps authorization_pending to pending', async () => {
@@ -119,8 +124,7 @@ describe('DeviceFlowApi.pollOnce', () => {
const api = new DeviceFlowApi(testHttpClient(stub.url))
const r = await api.pollOnce({ device_code: 'dc' })
expect(r.status).toBe('pending')
}
finally {
} finally {
await stub?.stop()
}
})
@@ -152,8 +156,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(404, {}))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i)
}
finally {
} finally {
await stub?.stop()
}
})
@@ -171,8 +174,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(200, {}))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i)
}
finally {
} finally {
await stub?.stop()
}
})
@@ -183,8 +185,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(400, { error: 'something_else' }))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/)
}
finally {
} finally {
await stub?.stop()
}
})
+4 -4
View File
@@ -36,7 +36,7 @@ describe('FileUploadClient.upload', () => {
it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => {
const filePath = join(dir, 'hello.png')
await writeFile(filePath, 'hello')
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
const result = await makeClient(stub.url).upload('app-1', filePath)
@@ -57,7 +57,7 @@ describe('FileUploadClient.upload', () => {
it('encodes the app id in the path', async () => {
const filePath = join(dir, 'a.txt')
await writeFile(filePath, 'x')
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
await makeClient(stub.url).upload('app/with space', filePath)
@@ -67,10 +67,10 @@ describe('FileUploadClient.upload', () => {
it('propagates a server 413 as a classified BaseError', async () => {
const filePath = join(dir, 'big.bin')
await writeFile(filePath, 'data')
stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap))
stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap))
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 413,
(err) => isHttpClientError(err) && err.httpStatus === 413,
)
})
})
+4 -4
View File
@@ -64,9 +64,9 @@ export class FileUploadClient {
const form = new FormData()
form.append('file', blob, filename)
return this.http.post<UploadedFile>(
`apps/${encodeURIComponent(appId)}/files/upload`,
{ body: form, timeoutMs: 60_000 },
)
return this.http.post<UploadedFile>(`apps/${encodeURIComponent(appId)}/files/upload`, {
body: form,
timeoutMs: 60_000,
})
}
}
+30 -27
View File
@@ -18,7 +18,7 @@ describe('MembersClient.list', () => {
})
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
@@ -26,12 +26,11 @@ describe('MembersClient.list', () => {
limit: 20,
total: 1,
has_more: false,
data: [
{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' },
],
data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }],
},
cap,
))
),
)
const result = await makeClient(stub.url).list('ws-1')
@@ -41,8 +40,9 @@ describe('MembersClient.list', () => {
})
it('URL-encodes workspace id', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap))
stub = await startStubServer((cap) =>
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap),
)
await makeClient(stub.url).list('ws with space')
@@ -50,8 +50,9 @@ describe('MembersClient.list', () => {
})
it('forwards page/limit as query params', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap))
stub = await startStubServer((cap) =>
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap),
)
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
@@ -59,18 +60,18 @@ describe('MembersClient.list', () => {
})
it('propagates server 403 as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 403,
(err) => isHttpClientError(err) && err.httpStatus === 403,
)
})
it('propagates 404 as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
@@ -83,7 +84,7 @@ describe('MembersClient.invite', () => {
})
it('POSTs JSON body and returns parsed invite response', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
201,
{
@@ -95,7 +96,8 @@ describe('MembersClient.invite', () => {
tenant_id: 'ws-1',
},
cap,
))
),
)
const result = await makeClient(stub.url).invite('ws-1', {
email: 'new@e.com',
@@ -113,11 +115,11 @@ describe('MembersClient.invite', () => {
})
it('propagates 400 (already in tenant) as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap))
await expect(
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
})
})
@@ -129,7 +131,7 @@ describe('MembersClient.remove', () => {
})
it('DELETEs member by id and returns success', async () => {
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
@@ -139,10 +141,10 @@ describe('MembersClient.remove', () => {
})
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap))
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 400,
(err) => isHttpClientError(err) && err.httpStatus === 400,
)
})
})
@@ -155,7 +157,7 @@ describe('MembersClient.updateRole', () => {
})
it('PUTs role payload to /role subresource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
@@ -166,11 +168,11 @@ describe('MembersClient.updateRole', () => {
})
it('propagates 400 (admin cannot demote owner)', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap))
await expect(
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
})
})
@@ -182,7 +184,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
})
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
@@ -194,7 +196,8 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
created_at: '2026-05-18T00:00:00Z',
},
cap,
))
),
)
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
const result = await client.switch('ws-1')
@@ -205,11 +208,11 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
})
it('propagates 404 (non-member)', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
await expect(client.switch('ws-x')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
+4 -1
View File
@@ -22,7 +22,10 @@ export class MembersClient {
this.orpc = createOpenApiClient(http)
}
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
async list(
workspaceId: string,
q?: { page?: number; limit?: number },
): Promise<MemberListResponse> {
return this.orpc.workspaces.byWorkspaceId.members.get({
params: { workspace_id: workspaceId },
query: { page: q?.page, limit: q?.limit },
+13 -17
View File
@@ -46,13 +46,13 @@ export type PollSuccess = {
token_id?: string
}
export type PollResult
= | { status: 'pending' }
| { status: 'slow_down' }
| { status: 'expired' }
| { status: 'denied' }
| { status: 'retry_5xx' }
| { status: 'approved', success: PollSuccess }
export type PollResult =
| { status: 'pending' }
| { status: 'slow_down' }
| { status: 'expired' }
| { status: 'denied' }
| { status: 'retry_5xx' }
| { status: 'approved'; success: PollSuccess }
const POLL_ERROR_TO_STATUS: Record<string, PollResult['status']> = {
authorization_pending: 'pending',
@@ -77,8 +77,7 @@ export class DeviceFlowApi {
}
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label }
const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body })
if (res.status === 404)
throw versionSkew()
if (res.status === 404) throw versionSkew()
if (!res.ok) {
throw new HttpClientError({
code: ErrorCode.Server4xxOther,
@@ -86,7 +85,7 @@ export class DeviceFlowApi {
httpStatus: res.status,
})
}
return await res.json() as CodeResponse
return (await res.json()) as CodeResponse
}
async pollOnce(req: PollRequest): Promise<PollResult> {
@@ -98,16 +97,13 @@ export class DeviceFlowApi {
}
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code }
const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body })
if (res.status === 404)
throw versionSkew()
if (res.status >= 500)
return { status: 'retry_5xx' }
if (res.status === 404) throw versionSkew()
if (res.status >= 500) return { status: 'retry_5xx' }
let payload: { error?: string } & Partial<PollSuccess> = {}
try {
const text = await res.text()
payload = text === '' ? {} : JSON.parse(text) as typeof payload
}
catch (err) {
payload = text === '' ? {} : (JSON.parse(text) as typeof payload)
} catch (err) {
throw new BaseError({
code: ErrorCode.Unknown,
message: `decode poll response: ${(err as Error).message}`,
+13 -8
View File
@@ -22,12 +22,17 @@ describe('WorkspacesClient.list', () => {
})
it('GETs /workspaces and returns the parsed list', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, {
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
],
}, cap))
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
],
},
cap,
),
)
const res = await makeClient(stub.url).list()
@@ -37,10 +42,10 @@ describe('WorkspacesClient.list', () => {
})
it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 401,
(err) => isHttpClientError(err) && err.httpStatus === 401,
)
})
})
+4 -1
View File
@@ -1,4 +1,7 @@
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
import type {
WorkspaceDetailResponse,
WorkspaceListResponse,
} from '@dify/contracts/api/openapi/types.gen'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
+4 -2
View File
@@ -71,13 +71,15 @@ describe('notLoggedInError', () => {
expect(notLoggedInError().toString()).toMatch(/auth login/)
})
it('accepts a custom hint', () => {
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/)
})
})
describe('Registry (pure)', () => {
const baseReg = (): Registry => Registry.empty('file')
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
const ctx = (email: string): AccountContext => ({
account: { id: `id-${email}`, email, name: email },
})
it('upsert creates host + account; remove drops them', () => {
const reg = baseReg()
+28 -32
View File
@@ -60,7 +60,7 @@ export type ActiveContext = {
readonly scheme?: string
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
}
@@ -73,8 +73,7 @@ export class Registry {
static async load(): Promise<Registry> {
const raw = await getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return Registry.empty()
if (raw === null) return Registry.empty()
return new Registry(RegistrySchema.parse(raw))
}
@@ -86,31 +85,34 @@ export class 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 }
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
if (host === undefined || host === '') return undefined
const entry = this.data.hosts[host]
if (entry === undefined)
return undefined
if (entry === undefined) return undefined
const email = entry?.current_account
if (!email)
return undefined
if (!email) return undefined
const ctx = entry.accounts[email]
if (ctx === undefined)
return undefined
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)
if (active === undefined) throw notLoggedInError(hint)
return active
}
@@ -122,18 +124,14 @@ export class Registry {
remove(host: string, email: string): void {
const entry = this.data.hosts[host]
if (entry === undefined)
return
if (entry === undefined) return
const wasActive = entry.current_account === email
delete entry.accounts[email]
if (wasActive)
entry.current_account = undefined
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) {
if (this.data.current_host === host) this.data.current_host = undefined
} else if (wasActive && this.data.current_host === host) {
this.data.current_host = undefined
}
}
@@ -144,17 +142,14 @@ export class Registry {
setAccount(email: string): void {
const host = this.data.current_host
if (host === undefined)
return
if (host === undefined) return
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.current_account = email
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
if (entry !== undefined) entry.scheme = scheme
}
activate(host: string, email: string, ctx: AccountContext): void {
@@ -168,8 +163,9 @@ export class Registry {
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
try {
await store.remove(active.host, active.email)
} catch {
/* best-effort */
}
catch { /* best-effort */ }
this.remove(active.host, active.email)
await this.save()
}
+3 -6
View File
@@ -42,10 +42,8 @@ describe('app-info disk cache', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})
@@ -93,8 +91,7 @@ describe('app-info disk cache', () => {
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
if (platform() !== 'win32')
expect(s.mode & 0o777).toBe(0o600)
if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600)
})
it('missing cache file is not an error', async () => {
+10 -8
View File
@@ -46,7 +46,10 @@ export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<
return {
get: (host, appId) => state.entries.get(key(host, appId)),
set: async (host, appId, meta) => {
const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() }
const record: AppMetaCacheRecord = {
meta,
fetchedAt: (opts.now ?? (() => new Date()))().toISOString(),
}
state.entries.set(key(host, appId), record)
await writeEntries(store, state.entries)
},
@@ -70,12 +73,10 @@ async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord
let raw: Record<string, DiskEntry>
try {
raw = await store.get(ENTRIES_KEY)
}
catch {
} catch {
return out
}
for (const [k, e] of Object.entries(raw))
out.set(k, deserialize(e))
for (const [k, e] of Object.entries(raw)) out.set(k, deserialize(e))
return out
}
@@ -93,10 +94,11 @@ function deserialize(e: DiskEntry): AppMetaCacheRecord {
}
function filterFields(input: unknown): AppMetaFieldKey[] {
if (!Array.isArray(input))
return []
if (!Array.isArray(input)) return []
const valid = new Set<AppMetaFieldKey>([FieldInfo, FieldParameters, FieldInputSchema])
return input.filter((s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey))
return input.filter(
(s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey),
)
}
function serialize(record: AppMetaCacheRecord): DiskEntry {
+2 -4
View File
@@ -22,10 +22,8 @@ describe('NudgeStore', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})
+4 -8
View File
@@ -28,8 +28,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<Nudg
return {
canWarn: (host, now) => {
const last = memory.get(host)
if (last === undefined)
return true
if (last === undefined) return true
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
return elapsed >= intervalMs
},
@@ -51,21 +50,18 @@ async function readWarned(store: Store): Promise<Map<string, number>> {
let raw: Record<string, string>
try {
raw = await store.get(WARNED_KEY)
}
catch {
} catch {
return out
}
for (const [host, iso] of Object.entries(raw)) {
const t = Date.parse(iso)
if (!Number.isNaN(t))
out.set(host, t)
if (!Number.isNaN(t)) out.set(host, t)
}
return out
}
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
const warned: Record<string, string> = {}
for (const [host, t] of state)
warned[host] = new Date(t).toISOString()
for (const [host, t] of state) warned[host] = new Date(t).toISOString()
await store.set(WARNED_KEY, warned)
}
+12 -9
View File
@@ -41,13 +41,11 @@ export async function buildAuthedContext(
const io = realStreams(opts.format ?? '')
const reg = await Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
if (active === undefined) fail(cmd, opts, io)
const store = getTokenStore(reg.token_storage)
const bearer = await store.read(active.host, active.email)
if (bearer === '')
fail(cmd, opts, io)
if (bearer === '') fail(cmd, opts, io)
const host = hostWithScheme(active.host, active.scheme)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
@@ -62,7 +60,9 @@ export async function buildAuthedContext(
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() })
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
@@ -76,17 +76,20 @@ async function runCompatNudge(opts: {
await maybeNudgeCompat(opts.host, {
store,
probe: async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
const http = createHttpClient({
baseURL: openAPIBase(host),
timeoutMs: META_PROBE_TIMEOUT_MS,
retryAttempts: 0,
})
return new MetaClient(http).serverVersion()
},
emit: line => opts.io.err.write(line),
emit: (line) => opts.io.err.write(line),
isTty: opts.io.isOutTTY,
format: opts.io.outputFormat,
clientVersion: versionInfo.version,
color: opts.io.isErrTTY,
})
}
catch {
} catch {
// already swallowed inside maybeNudgeCompat; this is belt-and-braces
}
}
@@ -22,8 +22,7 @@ describe('resolveRetryAttempts', () => {
let caught: unknown
try {
resolveRetryAttempts({ flag: undefined, env: () => 'foo' })
}
catch (e) {
} catch (e) {
caught = e
}
expect((caught as { code: string }).code).toBe('usage_invalid_flag')
@@ -34,8 +33,7 @@ describe('resolveRetryAttempts', () => {
let caught: unknown
try {
resolveRetryAttempts({ flag: undefined, env: () => '-1' })
}
catch (e) {
} catch (e) {
caught = e
}
expect((caught as { code: string }).code).toBe('usage_invalid_flag')
+9 -8
View File
@@ -5,7 +5,8 @@ import { Flags } from '@/framework/flags'
export const HTTP_RETRY_DEFAULT = 3
export const httpRetryFlag = Flags.integer({
description: 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
description:
'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
helpGroup: 'GLOBAL',
})
@@ -15,15 +16,15 @@ export type ResolveRetryAttemptsOpts = {
}
export function resolveRetryAttempts(opts: ResolveRetryAttemptsOpts): number {
if (opts.flag !== undefined)
return opts.flag
if (opts.flag !== undefined) return opts.flag
const raw = opts.env('DIFYCTL_HTTP_RETRY')
if (raw === undefined || raw === '')
return HTTP_RETRY_DEFAULT
if (raw === undefined || raw === '') return HTTP_RETRY_DEFAULT
if (!/^-?\d+$/.test(raw))
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`)
throw newError(
ErrorCode.UsageInvalidFlag,
`DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`,
)
const n = Number(raw)
if (n < 0)
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
if (n < 0) throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
return n
}
@@ -11,7 +11,11 @@ import { Registry } from '@/auth/hosts'
import { bufferStreams } from '@/sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
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' },
@@ -42,7 +46,7 @@ describe('runDevicesList', () => {
expect(out).toContain('difyctl on laptop')
expect(out).toContain('difyctl on desktop')
const lines = out.trim().split('\n')
const laptopLine = lines.find(l => l.includes('difyctl on laptop'))!
const laptopLine = lines.find((l) => l.includes('difyctl on laptop'))!
expect(laptopLine).toMatch(/\*\s*$/)
})
@@ -75,7 +79,15 @@ describe('runDevicesRevoke', () => {
await reg.save()
const http = testHttpClient(mock.url, 'dfoa_test')
await runDevicesRevoke({ io, reg, active, store, http, 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)
})
@@ -106,9 +118,9 @@ describe('runDevicesRevoke', () => {
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
await expect(
runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }),
).rejects.toThrow(/matches multiple/)
})
it('no match throws', async () => {
@@ -117,9 +129,9 @@ describe('runDevicesRevoke', () => {
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
await expect(
runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }),
).rejects.toThrow(/no session matches/)
})
it('--all: revokes everything except current', async () => {
@@ -151,9 +163,9 @@ describe('runDevicesRevoke', () => {
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
.rejects
.toThrow(/specify a device label/)
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })).rejects.toThrow(
/specify a device label/,
)
})
})
@@ -168,12 +180,14 @@ describe('listAllSessions', () => {
expires_at: null,
})
function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType<typeof vi.fn> } {
const list = vi.fn(async (q?: { page?: number, limit?: number }) => {
function stubClient(pages: readonly SessionListResponse[]): {
client: AccountSessionsClient
list: ReturnType<typeof vi.fn>
} {
const list = vi.fn(async (q?: { page?: number; limit?: number }) => {
const page = q?.page ?? 1
const env = pages[page - 1]
if (env === undefined)
throw new Error(`stub: no page ${page}`)
if (env === undefined) throw new Error(`stub: no page ${page}`)
return env
})
return { client: { list } as unknown as AccountSessionsClient, list }
@@ -181,8 +195,20 @@ describe('listAllSessions', () => {
it('exhausts pages until has_more=false', async () => {
const { client, list } = stubClient([
{ page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) },
{ page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) },
{
page: 1,
limit: 200,
total: 250,
has_more: true,
data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)),
},
{
page: 2,
limit: 200,
total: 250,
has_more: false,
data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)),
},
])
const all = await listAllSessions(client)
expect(all.length).toBe(250)
@@ -25,9 +25,8 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
const env = opts.envLookup ?? ((k: string) => process.env[k])
const limit = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const envelope = await runWithSpinner(
{ io: opts.io, label: 'Fetching devices' },
() => sessions.list({ page, limit }),
const envelope = await runWithSpinner({ io: opts.io, label: 'Fetching devices' }, () =>
sessions.list({ page, limit }),
)
if (opts.json === true) {
@@ -39,11 +38,9 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
if (raw !== undefined && raw !== '')
return parseLimit(raw, '--limit')
if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit')
const envValue = env('DIFY_LIMIT')
if (envValue !== undefined && envValue !== '')
return parseLimit(envValue, 'DIFY_LIMIT')
if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT')
return LIMIT_DEFAULT
}
@@ -52,7 +49,9 @@ function resolveLimit(raw: string | undefined, env: (k: string) => string | unde
* session sitting on page 2+ is still findable / revocable. Uses the max
* page size (LIMIT_MAX) to minimize round-trips.
*/
export async function listAllSessions(client: AccountSessionsClient): Promise<readonly SessionRow[]> {
export async function listAllSessions(
client: AccountSessionsClient,
): Promise<readonly SessionRow[]> {
const out: SessionRow[] = []
let page = 1
// Hard guard against a misbehaving server that lies about has_more.
@@ -60,8 +59,7 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
while (page <= MAX_PAGES) {
const env = await client.list({ page, limit: LIMIT_MAX })
out.push(...env.data)
if (!env.has_more)
return out
if (!env.has_more) return out
page++
}
return out
@@ -84,7 +82,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'specify a device label / id, or pass --all',
hint: 'see \'difyctl auth devices list\'',
hint: "see 'difyctl auth devices list'",
})
}
@@ -96,11 +94,9 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
return
}
for (const id of ids)
await sessions.revoke(id)
for (const id of ids) await sessions.revoke(id)
if (selfHit)
await opts.reg.forget(opts.active, opts.store)
if (selfHit) await opts.reg.forget(opts.active, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@@ -110,30 +106,29 @@ export type PickResult = {
selfHit: boolean
}
export function pickTargets(rows: readonly SessionRow[], opts: { target?: string, all: boolean }, currentId: string): PickResult {
export function pickTargets(
rows: readonly SessionRow[],
opts: { target?: string; all: boolean },
currentId: string,
): PickResult {
if (opts.all) {
const ids = rows.filter(r => r.id !== currentId).map(r => r.id)
const ids = rows.filter((r) => r.id !== currentId).map((r) => r.id)
return { ids, selfHit: false }
}
const target = opts.target ?? ''
const byLabel = rows.filter(r => r.device_label === target)
if (byLabel.length > 1)
throw ambiguous(target, byLabel)
const byLabel = rows.filter((r) => r.device_label === target)
if (byLabel.length > 1) throw ambiguous(target, byLabel)
const onlyLabel = byLabel[0]
if (onlyLabel !== undefined)
return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
const byId = rows.find(r => r.id === target)
if (byId !== undefined)
return { ids: [byId.id], selfHit: byId.id === currentId }
const byId = rows.find((r) => r.id === target)
if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId }
const needle = target.toLowerCase()
const bySub = rows.filter(r => r.device_label.toLowerCase().includes(needle))
if (bySub.length > 1)
throw ambiguous(target, bySub)
const bySub = rows.filter((r) => r.device_label.toLowerCase().includes(needle))
if (bySub.length > 1) throw ambiguous(target, bySub)
const onlySub = bySub[0]
if (onlySub !== undefined)
return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
throw new BaseError({
code: ErrorCode.UsageMissingArg,
@@ -142,7 +137,7 @@ export function pickTargets(rows: readonly SessionRow[], opts: { target?: string
}
function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
const labels = rows.map(r => `${r.device_label} (${r.id})`).join(', ')
const labels = rows.map((r) => `${r.device_label} (${r.id})`).join(', ')
return new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`,
@@ -151,14 +146,19 @@ function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
function renderTable(rows: readonly SessionRow[], currentId: string): string {
const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT']
const body = rows.map(r => [
const body = rows.map((r) => [
r.device_label !== '' ? r.device_label : r.id,
r.created_at ?? '',
r.last_used_at ?? '',
r.id === currentId ? '*' : '',
])
const widths = header.map((h, i) => Math.max(h.length, ...body.map(row => (row[i] ?? '').length)))
const widths = header.map((h, i) =>
Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)),
)
const fmt = (cells: readonly string[]): string =>
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
cells
.map((c, i) => c.padEnd(widths[i] ?? 0))
.join(' ')
.trimEnd()
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
}
+3 -3
View File
@@ -14,9 +14,9 @@ export default class DevicesList extends DifyCommand {
static override flags = {
'http-retry': httpRetryFlag,
'json': Flags.boolean({ description: 'emit JSON', default: false }),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
json: Flags.boolean({ description: 'emit JSON', default: false }),
page: Flags.integer({ description: 'page number', default: 1 }),
limit: Flags.string({ description: 'page size [1..200]' }),
}
async run(argv: string[]): Promise<void> {
@@ -19,9 +19,12 @@ export default class DevicesRevoke extends DifyCommand {
}
static override flags = {
'all': Flags.boolean({ description: 'revoke every session except the current one', default: false }),
all: Flags.boolean({
description: 'revoke every session except the current one',
default: false,
}),
'http-retry': httpRetryFlag,
'yes': Flags.boolean({ description: 'skip confirmation prompt', default: false }),
yes: Flags.boolean({ description: 'skip confirmation prompt', default: false }),
}
async run(argv: string[]): Promise<void> {
+3 -3
View File
@@ -52,14 +52,14 @@ export class ContextListOutput {
}
tableRows(): readonly (readonly TableCell[])[] {
return this.rows.map(r => r.tableRow())
return this.rows.map((r) => r.tableRow())
}
name(): string {
return this.rows.map(r => r.name()).join('\n')
return this.rows.map((r) => r.name()).join('\n')
}
json() {
return { contexts: this.rows.map(r => r.json()) }
return { contexts: this.rows.map((r) => r.json()) }
}
}

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