mirror of
https://github.com/langgenius/dify.git
synced 2026-05-29 21:27:54 +08:00
Compare commits
195 Commits
fix/cli-to
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d4e494162 | |||
| 4a0b177eee | |||
| 9490d63c50 | |||
| 15b2a8fdb7 | |||
| 65098a6b4f | |||
| 8055f8840c | |||
| f033f91a68 | |||
| 0b98319bd3 | |||
| 386de25e26 | |||
| d30805353a | |||
| 21c5825508 | |||
| 2d324add39 | |||
| 62beaf493e | |||
| b34e5aa915 | |||
| 2ea19c2b1c | |||
| 5712f29e8b | |||
| 93f7404c6b | |||
| 4f631d6f4c | |||
| 2b3e15cc83 | |||
| 5e1fac09bb | |||
| 35956247ab | |||
| 343531b9dc | |||
| 236f389fce | |||
| 5a2604265c | |||
| 6a8aaa5a36 | |||
| 0ad1e8c2d9 | |||
| 8c7540f698 | |||
| bf345136eb | |||
| c9ed50c3ae | |||
| c78c603a38 | |||
| 48b38446a3 | |||
| 8a8bec4bc6 | |||
| 89571bd241 | |||
| afee58cca7 | |||
| 76a55535f2 | |||
| 29cb993042 | |||
| 00581a4daa | |||
| bfc71bb087 | |||
| a95a6ea263 | |||
| 264e97a4c2 | |||
| 95936a8bac | |||
| ac8a1107ca | |||
| 43f67ef2d1 | |||
| c118fe9ad2 | |||
| 0c96426d91 | |||
| 67fee14770 | |||
| d94006162d | |||
| 3d53cee8a9 | |||
| 1acd1b568a | |||
| 68f939f3b3 | |||
| 1f4b76ba7e | |||
| 4d974d8f72 | |||
| 1dc12d1661 | |||
| 82345977cd | |||
| 83c943bc21 | |||
| 7e34e2347a | |||
| 94a376a5a7 | |||
| 33f6b0c9aa | |||
| 2b130d0d2a | |||
| 33d95ab23a | |||
| 7a8a92082b | |||
| 4f9adfb9ae | |||
| f3974d6176 | |||
| ef00f850e4 | |||
| cb2e404eb6 | |||
| 14e7fc87e4 | |||
| 40b4c3476d | |||
| 1c641d2b44 | |||
| c3c9a349cc | |||
| 169293c8da | |||
| 7815228395 | |||
| dcd40b5004 | |||
| bcc4b208c7 | |||
| c252006644 | |||
| 9e5668c233 | |||
| 52ce49b3c6 | |||
| e90aa76ba2 | |||
| de9373e1b8 | |||
| 58923f38e6 | |||
| 8486a5b213 | |||
| 28a8be0d5f | |||
| f2d4d5b267 | |||
| f62a59a18a | |||
| b488812714 | |||
| 755760b97c | |||
| 955c3fb797 | |||
| 0c9aa20047 | |||
| 065246a9a7 | |||
| 0d12b5ab1c | |||
| 514dcae189 | |||
| 228dd84a91 | |||
| 336ddad096 | |||
| 92bb9a17b7 | |||
| b8868dab90 | |||
| 94225682cd | |||
| 18b6568c2a | |||
| a3a9ded29b | |||
| de78a26920 | |||
| c54d029e7c | |||
| ad4b9dc2c3 | |||
| cdec0c69a6 | |||
| 53acc3726c | |||
| b1d393f4d9 | |||
| 62e9bdd70d | |||
| d36c76c20e | |||
| f525e1a5eb | |||
| e2f779b20d | |||
| e198d6305c | |||
| 5e67514265 | |||
| b63896de87 | |||
| e463389f2c | |||
| cda348ca10 | |||
| ca48050666 | |||
| 9c0f592f34 | |||
| b70241ad36 | |||
| 4abe622b2e | |||
| 16c32c82e3 | |||
| 46424513d1 | |||
| 2c4baa20d8 | |||
| b0ae553f2e | |||
| 0266a12ee5 | |||
| 9d7765d5fd | |||
| d4ef983f42 | |||
| 018f36711d | |||
| dacd333e4a | |||
| b079a26314 | |||
| 7e953ebe0b | |||
| b4d28fca54 | |||
| 728c6b8201 | |||
| f56e23b5fd | |||
| 5600cefa53 | |||
| 561eb9cbd2 | |||
| 83766ca694 | |||
| 678be94d22 | |||
| 9e852429be | |||
| d93c5028f1 | |||
| 54f189305e | |||
| a610a24507 | |||
| 05e8a94bb5 | |||
| b2e2e7b60b | |||
| e7d2e66ff5 | |||
| c51069685c | |||
| 28c208f36a | |||
| 53a1386b87 | |||
| 0e366c7300 | |||
| 939bdde373 | |||
| 13dfa3aba4 | |||
| 2705a7c1db | |||
| 258a751b8c | |||
| 5a35d3d9cd | |||
| c3fbafae83 | |||
| f727c8f838 | |||
| 90af4c39b4 | |||
| f7c3a4e4cb | |||
| be7d043edd | |||
| cef8fe3a4b | |||
| afe0e6c393 | |||
| 37309b931e | |||
| 6a83c6705c | |||
| 3e75d5e443 | |||
| 7be8a5b883 | |||
| 80dcb344f4 | |||
| b029c9b1cd | |||
| 6cb97e9201 | |||
| 4ef2e952bd | |||
| cc5545339c | |||
| 0a8c46a3a7 | |||
| 65770903d1 | |||
| 5a6ba2ffb5 | |||
| aa53afe07d | |||
| 4740a89f4a | |||
| 328db3d67a | |||
| 88062fb247 | |||
| 045da59220 | |||
| 948b0f6bc7 | |||
| 14a59f6e44 | |||
| f9f361113e | |||
| eea6f59307 | |||
| 718f69dc43 | |||
| 82a2ba9264 | |||
| 6c8e032fbb | |||
| 28c2c3bfd3 | |||
| 9d463e1024 | |||
| 7f87616625 | |||
| 43a04ed0c2 | |||
| 5083edd0ce | |||
| 8306fa41b9 | |||
| 8f33305e90 | |||
| 7077a43c1c | |||
| 884a43ae0a | |||
| 914f89f478 | |||
| 163153db18 | |||
| 49d890d514 | |||
| 0292bc2728 | |||
| 5c21120977 |
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: frontend-code-review
|
||||
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
|
||||
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support pending-change and focused file reviews while applying checklist rules, shared component reuse checks, and React component structure guidance from how-to-write-component."
|
||||
---
|
||||
|
||||
# Frontend Code Review
|
||||
@ -16,10 +16,12 @@ Stick to the checklist below for every applicable file and mode.
|
||||
## Checklist
|
||||
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
|
||||
|
||||
When reviewing React/TypeScript components, also apply the repo-local `how-to-write-component` skill as the component architecture checklist. In particular, check ownership boundaries, props and API types, query/mutation usage, navigation choices, effect usage, unnecessary wrappers, and unnecessary memoization.
|
||||
|
||||
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
|
||||
|
||||
## Review Process
|
||||
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
|
||||
1. Open the relevant component/module. Gather lines that relate to shared base/dify-ui component reuse, class names, styling/CSS imports, file size and component boundaries, i18n keys, behavior-sensitive UI interactions, React Flow hooks, and prop memoization.
|
||||
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
|
||||
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
|
||||
|
||||
@ -70,4 +72,3 @@ If you use Template A (i.e., there are issues to fix) and at least one issue req
|
||||
## Code review
|
||||
No issues found.
|
||||
```
|
||||
|
||||
|
||||
@ -13,3 +13,29 @@ Node components are also used when creating a RAG Pipe from a template, but in t
|
||||
### Suggested Fix
|
||||
|
||||
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.
|
||||
|
||||
## Locale keys must be complete
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
When adding or changing user-facing i18n keys, ensure every supported locale file has the same key set as `web/i18n/en-US/`. Do not add only English keys or only a partial subset of locales; `pnpm i18n:check --file <name>` should pass for the touched translation file.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Add matching keys to every existing supported locale file for the touched translation namespace, keeping key paths aligned with the English entry.
|
||||
|
||||
## Preserve behavior-sensitive interactions
|
||||
|
||||
IsUrgent: True
|
||||
Category: Business Logic
|
||||
|
||||
### Description
|
||||
|
||||
When changing existing navigation, sidebar, dropdown, webapp list, or app-switching UI, compare behavior against the existing implementation before approving the change. Watch for regressions in expand/collapse arrows, hover persistence, pin/delete controls, routing, keyboard/focus handling, and open-state ownership.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Reuse or extend the existing component when it already owns the interaction logic. If a refactor is needed, preserve the old interaction contract and add or update focused tests for the changed behavior.
|
||||
|
||||
@ -7,12 +7,12 @@ Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
|
||||
Ensure conditional CSS and multi-line class composition are handled via the shared `cn` helper instead of custom ternaries, string concatenation, array `.join(' ')`, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
```ts
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
|
||||
```
|
||||
|
||||
@ -25,7 +25,34 @@ Category: Code Quality
|
||||
|
||||
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
|
||||
|
||||
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
|
||||
## CSS files must be scoped
|
||||
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
When CSS is truly necessary, use component-scoped `*.module.css`. Do not add component-level CSS through plain `.css` files, and do not import component CSS from `globals.css`; both patterns risk style leakage across the app.
|
||||
|
||||
## Split oversized components cautiously
|
||||
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
When a frontend file grows large or mixes multiple responsibilities, suggest splitting it into focused components, hooks, or utilities. Prefer shallow local structure that matches existing repo patterns, such as a sibling `components/` folder, and avoid deep folder hierarchies unless the surrounding code already uses them.
|
||||
|
||||
## Reuse base and dify-ui components before hand-rolling UI
|
||||
|
||||
Category: Code Quality
|
||||
|
||||
### Description
|
||||
|
||||
Before approving new or modified frontend UI, check whether the code manually recreates behavior or styling already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`. Common examples include `Button`, `Input`, `ToggleGroup`, `Popover`, `DropdownMenu`, `AlertDialog`, `Switch`, `Avatar`, `ScrollArea`, `toast`, and existing feature components. Prefer composing existing primitives instead of duplicating borders, focus states, disabled states, segmented controls, inputs, overlays, or buttons.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Replace hand-written UI chrome with the nearest shared primitive, keeping feature-specific layout, state ownership, labels, and workflow behavior local.
|
||||
|
||||
## Classname ordering for easy overrides
|
||||
|
||||
@ -36,9 +63,11 @@ When writing components, always place the incoming `className` prop after the co
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
const Button = ({ className }) => {
|
||||
return <div className={cn('bg-primary-600', className)}></div>
|
||||
}
|
||||
```
|
||||
|
||||
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
|
||||
|
||||
@ -43,3 +43,14 @@ const config = useMemo(() => ({
|
||||
config={config}
|
||||
/>
|
||||
```
|
||||
|
||||
## Custom SVG icon generation
|
||||
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
New custom SVG icons should be added to `packages/iconify-collections/assets/...`, generated with `pnpm --filter @dify/iconify-collections generate`, checked with `pnpm --filter @dify/iconify-collections check:dimensions`, and consumed through Tailwind `i-custom-*` classes. Do not add new generated React icon components or JSON files under `web/app/components/base/icons/src/...` for new custom SVG icons.
|
||||
|
||||
When reviewing generated `packages/iconify-collections/custom-*/icons.json` diffs, verify unrelated existing icons did not lose or change intrinsic `width` / `height`.
|
||||
33
.agents/skills/karpathy-guidelines/SKILL.md
Normal file
33
.agents/skills/karpathy-guidelines/SKILL.md
Normal file
@ -0,0 +1,33 @@
|
||||
---
|
||||
name: karpathy-guidelines
|
||||
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
|
||||
---
|
||||
|
||||
# Karpathy Guidelines
|
||||
|
||||
Use this skill whenever you touch code in this repository.
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep the change small and directly tied to the user request.
|
||||
- Prefer the simplest implementation that fits the existing codebase.
|
||||
- Read the nearby code first, then match its patterns.
|
||||
- Avoid unrelated refactors, broad rewrites, or style churn.
|
||||
- Preserve existing behavior unless the user explicitly asked to change it.
|
||||
- Treat regressions as a signal to narrow the change, not to add workaround layers.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current implementation and tests around the change.
|
||||
2. Make the smallest coherent edit.
|
||||
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
|
||||
4. Run the narrowest relevant verification first.
|
||||
5. Report exactly what was verified and anything left unverified.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does this change solve the stated problem without expanding scope?
|
||||
- Did it preserve existing route/component/data-flow semantics?
|
||||
- Are new abstractions justified by real complexity?
|
||||
- Are tests focused on the behavior that could regress?
|
||||
- Are unrelated files and generated artifacts left alone?
|
||||
4
MOCKS_TO_REMOVE_BEFORE_RELEASE.md
Normal file
4
MOCKS_TO_REMOVE_BEFORE_RELEASE.md
Normal file
@ -0,0 +1,4 @@
|
||||
# Mocks to Remove Before Release
|
||||
|
||||
- `emptyAppList=true`: frontend URL preview flag for forcing the `/apps` page into the first-empty state. Remove the parser and rendering override before release.
|
||||
- `emptyDataList=true`: frontend URL preview flag for forcing the `/datasets` page into the first-empty state. Remove the parser and rendering override before release.
|
||||
@ -11,6 +11,7 @@ from .data_migration import (
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .plugin import (
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
install_plugins,
|
||||
@ -49,6 +50,7 @@ from .vector import (
|
||||
__all__ = [
|
||||
"add_qdrant_index",
|
||||
"archive_workflow_runs",
|
||||
"backfill_plugin_auto_upgrade",
|
||||
"clean_expired_messages",
|
||||
"clean_workflow_runs",
|
||||
"cleanup_orphaned_draft_variables",
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
from configs import dify_config
|
||||
@ -15,11 +16,13 @@ from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import encrypt_system_params
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID, ToolProviderID
|
||||
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from models.tools import ToolOAuthSystemClient
|
||||
from services.plugin.data_migration import PluginDataMigration
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -402,6 +405,110 @@ def migrate_data_for_plugin():
|
||||
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
|
||||
|
||||
|
||||
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
|
||||
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
|
||||
stmt = (
|
||||
select(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
|
||||
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
|
||||
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
|
||||
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
|
||||
|
||||
|
||||
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
|
||||
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
|
||||
yield from db.session.scalars(stmt)
|
||||
|
||||
|
||||
@click.command(
|
||||
"backfill-plugin-auto-upgrade",
|
||||
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
|
||||
)
|
||||
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
|
||||
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
|
||||
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
|
||||
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
|
||||
def backfill_plugin_auto_upgrade(
|
||||
tenant_id: tuple[str, ...],
|
||||
limit: int | None,
|
||||
batch_size: int,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
Backfill historical auto-upgrade strategies after the category column exists.
|
||||
|
||||
Missing category rows are created from the tenant's tool/default row. Pure default
|
||||
strategies become latest for model plugins and fix-only for all other categories.
|
||||
Tenants with include/exclude plugin IDs are split
|
||||
by installed plugin category using plugin daemon metadata.
|
||||
"""
|
||||
start_at = time.perf_counter()
|
||||
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
|
||||
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
|
||||
|
||||
if dry_run:
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
|
||||
return
|
||||
|
||||
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
|
||||
|
||||
backfilled_count = 0
|
||||
created_count = 0
|
||||
normalized_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
for index, current_tenant_id in enumerate(tenant_ids, start=1):
|
||||
try:
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(
|
||||
current_tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
|
||||
continue
|
||||
|
||||
if result.created_count > 0:
|
||||
backfilled_count += 1
|
||||
created_count += result.created_count
|
||||
elif not result.normalized:
|
||||
skipped_count += 1
|
||||
if result.normalized:
|
||||
normalized_count += 1
|
||||
|
||||
if batch_size > 0 and index % batch_size == 0:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Processed {index}/{candidate_count} tenants. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={time.perf_counter() - start_at:.2f}s",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Backfill plugin auto-upgrade strategy categories completed. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={elapsed:.2f}s",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command("extract-plugins", help="Extract plugins.")
|
||||
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
|
||||
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)
|
||||
|
||||
@ -149,19 +149,28 @@ class InstalledAppsListApi(Resource):
|
||||
if current_user.current_tenant is None:
|
||||
raise ValueError("current_user.current_tenant must not be None")
|
||||
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
|
||||
installed_app_list: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": installed_app.id,
|
||||
"app": installed_app.app,
|
||||
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
|
||||
"is_pinned": installed_app.is_pinned,
|
||||
"last_used_at": installed_app.last_used_at,
|
||||
"editable": current_user.role in {"owner", "admin"},
|
||||
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
|
||||
}
|
||||
for installed_app in installed_apps
|
||||
if installed_app.app is not None
|
||||
]
|
||||
|
||||
app_ids = [installed_app.app_id for installed_app in installed_apps]
|
||||
apps = db.session.scalars(select(App).where(App.id.in_(app_ids))).all() if app_ids else []
|
||||
apps_by_id = {app.id: app for app in apps}
|
||||
|
||||
installed_app_list: list[dict[str, Any]] = []
|
||||
for installed_app in installed_apps:
|
||||
app_model = apps_by_id.get(installed_app.app_id)
|
||||
if app_model is None:
|
||||
continue
|
||||
|
||||
installed_app_list.append(
|
||||
{
|
||||
"id": installed_app.id,
|
||||
"app": app_model,
|
||||
"app_owner_tenant_id": installed_app.app_owner_tenant_id,
|
||||
"is_pinned": installed_app.is_pinned,
|
||||
"last_used_at": installed_app.last_used_at,
|
||||
"editable": current_user.role in {"owner", "admin"},
|
||||
"uninstallable": current_tenant_id == installed_app.app_owner_tenant_id,
|
||||
}
|
||||
)
|
||||
|
||||
# filter out apps that user doesn't have access to
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
|
||||
@ -64,15 +64,28 @@ class RecommendedAppListResponse(ResponseModel):
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class LearnDifyAppListResponse(ResponseModel):
|
||||
recommended_apps: list[RecommendedAppResponse]
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
RecommendedAppsQuery,
|
||||
RecommendedAppInfoResponse,
|
||||
RecommendedAppResponse,
|
||||
RecommendedAppListResponse,
|
||||
LearnDifyAppListResponse,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_language(language: str | None) -> str:
|
||||
if language and language in languages:
|
||||
return language
|
||||
if current_user and current_user.interface_language:
|
||||
return current_user.interface_language
|
||||
return languages[0]
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps")
|
||||
class RecommendedAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@ -82,13 +95,7 @@ class RecommendedAppListApi(Resource):
|
||||
def get(self):
|
||||
# language args
|
||||
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
language = args.language
|
||||
if language and language in languages:
|
||||
language_prefix = language
|
||||
elif current_user and current_user.interface_language:
|
||||
language_prefix = current_user.interface_language
|
||||
else:
|
||||
language_prefix = languages[0]
|
||||
language_prefix = _resolve_language(args.language)
|
||||
|
||||
return RecommendedAppListResponse.model_validate(
|
||||
RecommendedAppService.get_recommended_apps_and_categories(language_prefix),
|
||||
@ -96,6 +103,22 @@ class RecommendedAppListApi(Resource):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/learn-dify")
|
||||
class LearnDifyAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
language_prefix = _resolve_language(args.language)
|
||||
|
||||
return LearnDifyAppListResponse.model_validate(
|
||||
RecommendedAppService.get_learn_dify_apps(language_prefix),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/explore/apps/<uuid:app_id>")
|
||||
class RecommendedAppApi(Resource):
|
||||
@login_required
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import io
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
from flask import request, send_file
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SuccessResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.workspace import plugin_permission_required
|
||||
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
|
||||
@ -25,6 +30,14 @@ from services.plugin.plugin_parameter_service import PluginParameterService
|
||||
from services.plugin.plugin_permission_service import PluginPermissionService
|
||||
|
||||
|
||||
class AutoUpgradeSettingsResponse(TypedDict):
|
||||
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
|
||||
upgrade_time_of_day: int
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
|
||||
exclude_plugins: list[str]
|
||||
include_plugins: list[str]
|
||||
|
||||
|
||||
class ParserList(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
|
||||
@ -88,8 +101,8 @@ class ParserUninstall(BaseModel):
|
||||
|
||||
|
||||
class ParserPermissionChange(BaseModel):
|
||||
install_permission: TenantPluginPermission.InstallPermission
|
||||
debug_permission: TenantPluginPermission.DebugPermission
|
||||
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
|
||||
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
|
||||
|
||||
|
||||
class ParserDynamicOptions(BaseModel):
|
||||
@ -125,13 +138,40 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
|
||||
include_plugins: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ParserPreferencesChange(BaseModel):
|
||||
permission: PluginPermissionSettingsPayload
|
||||
class PluginAutoUpgradeChangeResponse(ResponseModel):
|
||||
success: bool
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
|
||||
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
|
||||
upgrade_time_of_day: int
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
|
||||
exclude_plugins: list[str]
|
||||
include_plugins: list[str]
|
||||
|
||||
|
||||
class PluginAutoUpgradeFetchResponse(ResponseModel):
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
|
||||
|
||||
|
||||
class ParserAutoUpgradeChange(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
auto_upgrade: PluginAutoUpgradeSettingsPayload
|
||||
|
||||
|
||||
class ParserAutoUpgradeFetch(BaseModel):
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
|
||||
|
||||
class ParserExcludePlugin(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
plugin_id: str
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
|
||||
|
||||
class ParserReadme(BaseModel):
|
||||
@ -164,21 +204,53 @@ register_schema_models(
|
||||
ParserPermissionChange,
|
||||
ParserDynamicOptions,
|
||||
ParserDynamicOptionsWithCredentials,
|
||||
ParserPreferencesChange,
|
||||
ParserAutoUpgradeChange,
|
||||
ParserAutoUpgradeFetch,
|
||||
ParserExcludePlugin,
|
||||
ParserReadme,
|
||||
)
|
||||
register_response_schema_models(console_ns, PluginDebuggingKeyResponse, SuccessResponse)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
PluginAutoUpgradeChangeResponse,
|
||||
PluginAutoUpgradeFetchResponse,
|
||||
PluginAutoUpgradeSettingsResponseModel,
|
||||
PluginDebuggingKeyResponse,
|
||||
SuccessResponse,
|
||||
)
|
||||
|
||||
register_enum_models(
|
||||
console_ns,
|
||||
TenantPluginPermission.DebugPermission,
|
||||
TenantPluginAutoUpgradeStrategy.PluginCategory,
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||
TenantPluginAutoUpgradeStrategy.StrategySetting,
|
||||
TenantPluginPermission.InstallPermission,
|
||||
)
|
||||
|
||||
|
||||
def _default_auto_upgrade_settings(
|
||||
tenant_id: str,
|
||||
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
|
||||
) -> AutoUpgradeSettingsResponse:
|
||||
return {
|
||||
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
|
||||
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
|
||||
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
"exclude_plugins": [],
|
||||
"include_plugins": [],
|
||||
}
|
||||
|
||||
|
||||
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
|
||||
return {
|
||||
"strategy_setting": strategy.strategy_setting,
|
||||
"upgrade_time_of_day": strategy.upgrade_time_of_day,
|
||||
"upgrade_mode": strategy.upgrade_mode,
|
||||
"exclude_plugins": strategy.exclude_plugins,
|
||||
"include_plugins": strategy.include_plugins,
|
||||
}
|
||||
|
||||
|
||||
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
|
||||
"""
|
||||
Read the uploaded file and validate its actual size before delegating to the plugin service.
|
||||
@ -632,11 +704,13 @@ class PluginChangePermissionApi(Resource):
|
||||
|
||||
tenant_id = current_tenant_id
|
||||
|
||||
return {
|
||||
"success": PluginPermissionService.change_permission(
|
||||
tenant_id, args.install_permission, args.debug_permission
|
||||
)
|
||||
}
|
||||
set_permission_result = PluginPermissionService.change_permission(
|
||||
tenant_id, args.install_permission, args.debug_permission
|
||||
)
|
||||
if not set_permission_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
|
||||
|
||||
return jsonable_encoder({"success": True})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/permission/fetch")
|
||||
@ -725,9 +799,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
|
||||
return jsonable_encoder({"options": options})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/change")
|
||||
class PluginChangePreferencesApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
|
||||
class PluginChangeAutoUpgradeApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -736,38 +811,17 @@ class PluginChangePreferencesApi(Resource):
|
||||
if not user.is_admin_or_owner:
|
||||
raise Forbidden()
|
||||
|
||||
args = ParserPreferencesChange.model_validate(console_ns.payload)
|
||||
|
||||
permission = args.permission
|
||||
|
||||
install_permission = permission.install_permission
|
||||
debug_permission = permission.debug_permission
|
||||
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
|
||||
|
||||
auto_upgrade = args.auto_upgrade
|
||||
|
||||
strategy_setting = auto_upgrade.strategy_setting
|
||||
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
|
||||
upgrade_mode = auto_upgrade.upgrade_mode
|
||||
exclude_plugins = auto_upgrade.exclude_plugins
|
||||
include_plugins = auto_upgrade.include_plugins
|
||||
|
||||
# set permission
|
||||
set_permission_result = PluginPermissionService.change_permission(
|
||||
tenant_id,
|
||||
install_permission,
|
||||
debug_permission,
|
||||
)
|
||||
if not set_permission_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
|
||||
|
||||
# set auto upgrade strategy
|
||||
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
strategy_setting,
|
||||
upgrade_time_of_day,
|
||||
upgrade_mode,
|
||||
exclude_plugins,
|
||||
include_plugins,
|
||||
auto_upgrade.strategy_setting,
|
||||
auto_upgrade.upgrade_time_of_day,
|
||||
auto_upgrade.upgrade_mode,
|
||||
auto_upgrade.exclude_plugins,
|
||||
auto_upgrade.include_plugins,
|
||||
category=args.category,
|
||||
)
|
||||
if not set_auto_upgrade_strategy_result:
|
||||
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
|
||||
@ -775,48 +829,36 @@ class PluginChangePreferencesApi(Resource):
|
||||
return jsonable_encoder({"success": True})
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
|
||||
class PluginFetchPreferencesApi(Resource):
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
|
||||
class PluginFetchAutoUpgradeApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
|
||||
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
|
||||
permission = PluginPermissionService.get_permission(tenant_id)
|
||||
permission_dict = {
|
||||
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
|
||||
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
|
||||
}
|
||||
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
|
||||
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
|
||||
auto_upgrade_dict = (
|
||||
_auto_upgrade_settings_to_dict(auto_upgrade)
|
||||
if auto_upgrade
|
||||
else _default_auto_upgrade_settings(tenant_id, args.category)
|
||||
)
|
||||
|
||||
if permission:
|
||||
permission_dict["install_permission"] = permission.install_permission
|
||||
permission_dict["debug_permission"] = permission.debug_permission
|
||||
|
||||
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
|
||||
auto_upgrade_dict = {
|
||||
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
|
||||
"upgrade_time_of_day": 0,
|
||||
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
"exclude_plugins": [],
|
||||
"include_plugins": [],
|
||||
}
|
||||
|
||||
if auto_upgrade:
|
||||
auto_upgrade_dict = {
|
||||
"strategy_setting": auto_upgrade.strategy_setting,
|
||||
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
|
||||
"upgrade_mode": auto_upgrade.upgrade_mode,
|
||||
"exclude_plugins": auto_upgrade.exclude_plugins,
|
||||
"include_plugins": auto_upgrade.include_plugins,
|
||||
return jsonable_encoder(
|
||||
{
|
||||
"category": args.category,
|
||||
"auto_upgrade": auto_upgrade_dict,
|
||||
}
|
||||
|
||||
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
|
||||
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
|
||||
class PluginAutoUpgradeExcludePluginApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -826,7 +868,9 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
|
||||
|
||||
args = ParserExcludePlugin.model_validate(console_ns.payload)
|
||||
|
||||
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
|
||||
return jsonable_encoder(
|
||||
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/plugin/readme")
|
||||
|
||||
@ -5,6 +5,7 @@ def init_app(app: DifyApp):
|
||||
from commands import (
|
||||
add_qdrant_index,
|
||||
archive_workflow_runs,
|
||||
backfill_plugin_auto_upgrade,
|
||||
clean_expired_messages,
|
||||
clean_workflow_runs,
|
||||
cleanup_orphaned_draft_variables,
|
||||
@ -53,6 +54,7 @@ def init_app(app: DifyApp):
|
||||
upgrade_db,
|
||||
fix_app_site_missing,
|
||||
migrate_data_for_plugin,
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
install_plugins,
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
"""add plugin auto upgrade category
|
||||
|
||||
Revision ID: f6a7b8c9d012
|
||||
Revises: a4f2d8c9b731
|
||||
Create Date: 2026-05-15 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f6a7b8c9d012"
|
||||
down_revision = "a4f2d8c9b731"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
LEGACY_CATEGORY = "tool"
|
||||
UNIQUE_CONSTRAINT_NAME = "unique_tenant_plugin_auto_upgrade_strategy"
|
||||
UPGRADE_TIME_INDEX_NAME = "idx_tenant_plugin_auto_upgrade_strategy_time"
|
||||
STRATEGY_TABLE_NAME = "tenant_plugin_auto_upgrade_strategies"
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("category", sa.String(length=32), server_default=LEGACY_CATEGORY, nullable=False)
|
||||
)
|
||||
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
|
||||
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id", "category"])
|
||||
batch_op.create_index(UPGRADE_TIME_INDEX_NAME, ["upgrade_time_of_day"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute(sa.text(f"DELETE FROM {STRATEGY_TABLE_NAME} WHERE category != '{LEGACY_CATEGORY}'"))
|
||||
|
||||
with op.batch_alter_table(STRATEGY_TABLE_NAME, schema=None) as batch_op:
|
||||
batch_op.drop_index(UPGRADE_TIME_INDEX_NAME)
|
||||
batch_op.drop_constraint(UNIQUE_CONSTRAINT_NAME, type_="unique")
|
||||
batch_op.drop_column("category")
|
||||
batch_op.create_unique_constraint(UNIQUE_CONSTRAINT_NAME, ["tenant_id"])
|
||||
@ -0,0 +1,26 @@
|
||||
"""add learn dify flag to recommended apps
|
||||
|
||||
Revision ID: f5e8a9c0d2b3
|
||||
Revises: a4f2d8c9b731
|
||||
Create Date: 2026-05-18 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f5e8a9c0d2b3"
|
||||
down_revision = "a4f2d8c9b731"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("is_learn_dify", sa.Boolean(), server_default=sa.text("false"), nullable=False))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("recommended_apps", schema=None) as batch_op:
|
||||
batch_op.drop_column("is_learn_dify")
|
||||
@ -389,6 +389,14 @@ class TenantPluginPermission(TypeBase):
|
||||
|
||||
|
||||
class TenantPluginAutoUpgradeStrategy(TypeBase):
|
||||
class PluginCategory(enum.StrEnum):
|
||||
TOOL = "tool"
|
||||
MODEL = "model"
|
||||
EXTENSION = "extension"
|
||||
AGENT_STRATEGY = "agent-strategy"
|
||||
DATASOURCE = "datasource"
|
||||
TRIGGER = "trigger"
|
||||
|
||||
class StrategySetting(enum.StrEnum):
|
||||
DISABLED = "disabled"
|
||||
FIX_ONLY = "fix_only"
|
||||
@ -402,13 +410,20 @@ class TenantPluginAutoUpgradeStrategy(TypeBase):
|
||||
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"),
|
||||
sa.UniqueConstraint("tenant_id", "category", name="unique_tenant_plugin_auto_upgrade_strategy"),
|
||||
sa.Index("idx_tenant_plugin_auto_upgrade_strategy_time", "upgrade_time_of_day"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
category: Mapped[PluginCategory] = mapped_column(
|
||||
EnumText(PluginCategory, length=32),
|
||||
nullable=False,
|
||||
server_default="tool",
|
||||
default=PluginCategory.TOOL,
|
||||
)
|
||||
strategy_setting: Mapped[StrategySetting] = mapped_column(
|
||||
EnumText(StrategySetting, length=16),
|
||||
nullable=False,
|
||||
|
||||
@ -882,6 +882,9 @@ class RecommendedApp(TypeBase):
|
||||
custom_disclaimer: Mapped[str] = mapped_column(LongText, default="")
|
||||
position: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
is_listed: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, default=True)
|
||||
is_learn_dify: Mapped[bool] = mapped_column(
|
||||
sa.Boolean, nullable=False, server_default=sa.text("false"), default=False
|
||||
)
|
||||
install_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(255),
|
||||
|
||||
@ -5845,6 +5845,21 @@ Delete an API key for a dataset
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | [RecommendedAppListResponse](#recommendedapplistresponse) |
|
||||
|
||||
### /explore/apps/learn-dify
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| language | query | Language code for recommended app localization | No | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | [LearnDifyAppListResponse](#learndifyapplistresponse) |
|
||||
|
||||
### /explore/apps/{app_id}
|
||||
|
||||
#### GET
|
||||
@ -9108,6 +9123,51 @@ Returns permission flags that control workspace features like member invitations
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/auto-upgrade/change
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [ParserAutoUpgradeChange](#parserautoupgradechange) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/auto-upgrade/exclude
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [ParserExcludePlugin](#parserexcludeplugin) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/auto-upgrade/fetch
|
||||
|
||||
#### GET
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| category | query | | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/debugging-key
|
||||
|
||||
#### GET
|
||||
@ -9310,45 +9370,6 @@ Fetch dynamic options using credentials directly (for edit mode)
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/preferences/autoupgrade/exclude
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [ParserExcludePlugin](#parserexcludeplugin) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/preferences/change
|
||||
|
||||
#### POST
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [ParserPreferencesChange](#parserpreferenceschange) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/preferences/fetch
|
||||
|
||||
#### GET
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Success |
|
||||
|
||||
### /workspaces/current/plugin/readme
|
||||
|
||||
#### GET
|
||||
@ -13677,6 +13698,12 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| LLMMode | string | Enum class for large language model mode. | |
|
||||
|
||||
#### LearnDifyAppListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| recommended_apps | [ [RecommendedAppResponse](#recommendedappresponse) ] | | Yes |
|
||||
|
||||
#### LegacyEndpointUpdatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -14186,6 +14213,19 @@ Form input definition.
|
||||
| file_name | string | | Yes |
|
||||
| plugin_unique_identifier | string | | Yes |
|
||||
|
||||
#### ParserAutoUpgradeChange
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
|
||||
| category | [PluginCategory](#plugincategory) | | Yes |
|
||||
|
||||
#### ParserAutoUpgradeFetch
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| category | [PluginCategory](#plugincategory) | | Yes |
|
||||
|
||||
#### ParserCreateCredential
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -14282,6 +14322,7 @@ Form input definition.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| category | [PluginCategory](#plugincategory) | | Yes |
|
||||
| plugin_id | string | | Yes |
|
||||
|
||||
#### ParserGetCredentials
|
||||
@ -14369,8 +14410,8 @@ Form input definition.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| debug_permission | [DebugPermission](#debugpermission) | | Yes |
|
||||
| install_permission | [InstallPermission](#installpermission) | | Yes |
|
||||
| debug_permission | [DebugPermission](#debugpermission) | | No |
|
||||
| install_permission | [InstallPermission](#installpermission) | | No |
|
||||
|
||||
#### ParserPluginIdentifierQuery
|
||||
|
||||
@ -14400,13 +14441,6 @@ Form input definition.
|
||||
| model | string | | Yes |
|
||||
| model_type | [ModelType](#modeltype) | | Yes |
|
||||
|
||||
#### ParserPreferencesChange
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| auto_upgrade | [PluginAutoUpgradeSettingsPayload](#pluginautoupgradesettingspayload) | | Yes |
|
||||
| permission | [PluginPermissionSettingsPayload](#pluginpermissionsettingspayload) | | Yes |
|
||||
|
||||
#### ParserPreferredProviderType
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -14516,6 +14550,12 @@ Form input definition.
|
||||
| upgrade_mode | [UpgradeMode](#upgrademode) | | No |
|
||||
| upgrade_time_of_day | integer | | No |
|
||||
|
||||
#### PluginCategory
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| PluginCategory | string | | |
|
||||
|
||||
#### PluginDebuggingKeyResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -73,6 +73,7 @@ def check_upgradable_plugin_task():
|
||||
strategy.upgrade_mode,
|
||||
strategy.exclude_plugins,
|
||||
strategy.include_plugins,
|
||||
strategy.category,
|
||||
)
|
||||
|
||||
# Only sleep if batch_interval_time > 0.0001 AND current batch is not the last one
|
||||
|
||||
@ -70,6 +70,7 @@ from services.errors.account import (
|
||||
)
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
from tasks.mail_change_mail_task import (
|
||||
@ -1133,15 +1134,17 @@ class TenantService:
|
||||
db.session.add(tenant)
|
||||
db.session.commit()
|
||||
|
||||
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant.id,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
db.session.add(plugin_upgrade_strategy)
|
||||
for category in TenantPluginAutoUpgradeStrategy.PluginCategory:
|
||||
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant.id,
|
||||
category=category,
|
||||
strategy_setting=PluginAutoUpgradeService.default_strategy_setting_for_category(category),
|
||||
upgrade_time_of_day=PluginAutoUpgradeService.default_upgrade_time_of_day(tenant.id),
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
db.session.add(plugin_upgrade_strategy)
|
||||
db.session.commit()
|
||||
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
|
||||
@ -149,7 +149,7 @@ class AppService:
|
||||
return None
|
||||
|
||||
app_models = db.paginate(
|
||||
sa.select(App).where(*filters).order_by(App.created_at.desc()),
|
||||
sa.select(App).where(*filters).order_by(App.updated_at.desc()),
|
||||
page=params.page,
|
||||
per_page=params.limit,
|
||||
error_out=False,
|
||||
|
||||
@ -1,18 +1,295 @@
|
||||
"""Manage tenant plugin auto-upgrade strategies.
|
||||
|
||||
The storage is category-scoped: each tenant can have one strategy per plugin
|
||||
category. Public mutation helpers require an explicit category so callers do
|
||||
not accidentally overwrite every plugin type with one workspace-level policy.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
PLUGIN_CATEGORIES = tuple(PluginCategory)
|
||||
SECONDS_PER_DAY = 24 * 60 * 60
|
||||
AUTO_UPGRADE_CHECK_SLOT_SECONDS = 15 * 60
|
||||
AUTO_UPGRADE_CHECK_SLOT_COUNT = SECONDS_PER_DAY // AUTO_UPGRADE_CHECK_SLOT_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginAutoUpgradeBackfillResult:
|
||||
created_count: int
|
||||
normalized: bool
|
||||
|
||||
|
||||
class PluginAutoUpgradeService:
|
||||
@staticmethod
|
||||
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
|
||||
with session_factory.create_session() as session:
|
||||
return session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
def default_strategy_setting_for_category(
|
||||
category: PluginCategory,
|
||||
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
|
||||
if category == PluginCategory.MODEL:
|
||||
return TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
|
||||
return TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
|
||||
|
||||
@staticmethod
|
||||
def default_upgrade_time_of_day(tenant_id: str) -> int:
|
||||
"""Spread default checks across 15-minute aligned slots by tenant."""
|
||||
hash_input = tenant_id.encode()
|
||||
slot = int.from_bytes(sha256(hash_input).digest()[:8], "big") % AUTO_UPGRADE_CHECK_SLOT_COUNT
|
||||
return slot * AUTO_UPGRADE_CHECK_SLOT_SECONDS
|
||||
|
||||
@staticmethod
|
||||
def _coerce_category(category: object) -> PluginCategory | None:
|
||||
"""Accept daemon enum/string categories and ignore unknown values."""
|
||||
category_value = getattr(category, "value", category)
|
||||
if category_value is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return PluginCategory(str(category_value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_installed_plugin_categories(tenant_id: str) -> dict[str, PluginCategory]:
|
||||
"""Build a plugin_id -> category map for splitting legacy include/exclude lists."""
|
||||
installed_plugins = PluginInstaller().list_plugins(tenant_id)
|
||||
plugin_categories: dict[str, PluginCategory] = {}
|
||||
|
||||
for plugin in installed_plugins:
|
||||
plugin_category = PluginAutoUpgradeService._coerce_category(plugin.declaration.category)
|
||||
if plugin_category is not None:
|
||||
plugin_categories[plugin.plugin_id] = plugin_category
|
||||
|
||||
return plugin_categories
|
||||
|
||||
@staticmethod
|
||||
def _filter_plugin_ids_for_category(
|
||||
plugin_ids: list[str],
|
||||
category: PluginCategory,
|
||||
plugin_categories: dict[str, PluginCategory],
|
||||
) -> list[str]:
|
||||
return [plugin_id for plugin_id in plugin_ids if plugin_categories.get(plugin_id) == category]
|
||||
|
||||
@staticmethod
|
||||
def _log_unknown_plugin_ids(
|
||||
tenant_id: str,
|
||||
field_name: str,
|
||||
plugin_ids: list[str],
|
||||
plugin_categories: dict[str, PluginCategory],
|
||||
) -> None:
|
||||
unknown_plugin_ids = [plugin_id for plugin_id in plugin_ids if plugin_id not in plugin_categories]
|
||||
if not unknown_plugin_ids:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
|
||||
"tenant_id=%s, field=%s, plugin_ids=%s",
|
||||
tenant_id,
|
||||
field_name,
|
||||
unknown_plugin_ids,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_default_strategy(strategy: TenantPluginAutoUpgradeStrategy) -> bool:
|
||||
return (
|
||||
strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY
|
||||
and strategy.upgrade_time_of_day == 0
|
||||
and strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||
and not strategy.exclude_plugins
|
||||
and not strategy.include_plugins
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _strategy_setting_for_category(
|
||||
source_strategy: TenantPluginAutoUpgradeStrategy,
|
||||
category: PluginCategory,
|
||||
source_has_default_strategy: bool,
|
||||
) -> TenantPluginAutoUpgradeStrategy.StrategySetting:
|
||||
# Only pure legacy defaults adopt the new model=latest default. User-edited
|
||||
# strategies keep their original setting across all categories.
|
||||
if source_has_default_strategy:
|
||||
return PluginAutoUpgradeService.default_strategy_setting_for_category(category)
|
||||
return source_strategy.strategy_setting
|
||||
|
||||
@staticmethod
|
||||
def _upgrade_time_of_day_for_category(
|
||||
tenant_id: str,
|
||||
source_strategy: TenantPluginAutoUpgradeStrategy,
|
||||
source_has_default_strategy: bool,
|
||||
) -> int:
|
||||
# Pure legacy defaults are spread by tenant so all default rows do not
|
||||
# concentrate in the same scheduler window. User-edited schedules keep their time.
|
||||
if source_has_default_strategy:
|
||||
return PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
return source_strategy.upgrade_time_of_day
|
||||
|
||||
@staticmethod
|
||||
def backfill_strategy_categories(
|
||||
tenant_id: str,
|
||||
) -> PluginAutoUpgradeBackfillResult:
|
||||
"""Create missing category strategies and split include/exclude lists when needed.
|
||||
|
||||
The historical row is treated as the workspace-level source strategy.
|
||||
New category rows copy it first, then plugin lists are narrowed by real
|
||||
plugin category when the source strategy contains include/exclude IDs.
|
||||
"""
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
strategies = list(
|
||||
session.scalars(
|
||||
select(TenantPluginAutoUpgradeStrategy).where(
|
||||
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not strategies:
|
||||
return PluginAutoUpgradeBackfillResult(created_count=0, normalized=False)
|
||||
|
||||
# Schema migration marks the historical workspace-level row as tool.
|
||||
source_strategy = next(
|
||||
(strategy for strategy in strategies if strategy.category == PluginCategory.TOOL),
|
||||
strategies[0],
|
||||
)
|
||||
source_has_default_strategy = PluginAutoUpgradeService._has_default_strategy(source_strategy)
|
||||
strategies_by_category = {strategy.category: strategy for strategy in strategies}
|
||||
exclude_plugins = source_strategy.exclude_plugins
|
||||
include_plugins = source_strategy.include_plugins
|
||||
should_split_plugin_lists = bool(exclude_plugins or include_plugins)
|
||||
# Query daemon only for tenants that actually customized plugin lists.
|
||||
plugin_categories = (
|
||||
PluginAutoUpgradeService._get_installed_plugin_categories(tenant_id)
|
||||
if should_split_plugin_lists
|
||||
else {}
|
||||
)
|
||||
if should_split_plugin_lists:
|
||||
PluginAutoUpgradeService._log_unknown_plugin_ids(
|
||||
tenant_id,
|
||||
"exclude_plugins",
|
||||
exclude_plugins,
|
||||
plugin_categories,
|
||||
)
|
||||
PluginAutoUpgradeService._log_unknown_plugin_ids(
|
||||
tenant_id,
|
||||
"include_plugins",
|
||||
include_plugins,
|
||||
plugin_categories,
|
||||
)
|
||||
|
||||
created_count = 0
|
||||
for category in PLUGIN_CATEGORIES:
|
||||
strategy = strategies_by_category.get(category)
|
||||
if strategy is None:
|
||||
# Start from the legacy workspace-level behavior before narrowing lists.
|
||||
strategy = TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=PluginAutoUpgradeService._strategy_setting_for_category(
|
||||
source_strategy, category, source_has_default_strategy
|
||||
),
|
||||
upgrade_time_of_day=PluginAutoUpgradeService._upgrade_time_of_day_for_category(
|
||||
tenant_id, source_strategy, source_has_default_strategy
|
||||
),
|
||||
upgrade_mode=source_strategy.upgrade_mode,
|
||||
exclude_plugins=source_strategy.exclude_plugins.copy(),
|
||||
include_plugins=source_strategy.include_plugins.copy(),
|
||||
)
|
||||
session.add(strategy)
|
||||
created_count += 1
|
||||
elif source_has_default_strategy:
|
||||
strategy.strategy_setting = PluginAutoUpgradeService.default_strategy_setting_for_category(
|
||||
strategy.category
|
||||
)
|
||||
strategy.upgrade_time_of_day = PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id)
|
||||
|
||||
if not should_split_plugin_lists:
|
||||
continue
|
||||
|
||||
# Narrow include/exclude lists to the current category after all rows exist.
|
||||
strategy.exclude_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
|
||||
exclude_plugins,
|
||||
strategy.category,
|
||||
plugin_categories,
|
||||
)
|
||||
strategy.include_plugins = PluginAutoUpgradeService._filter_plugin_ids_for_category(
|
||||
include_plugins,
|
||||
strategy.category,
|
||||
plugin_categories,
|
||||
)
|
||||
|
||||
return PluginAutoUpgradeBackfillResult(created_count=created_count, normalized=should_split_plugin_lists)
|
||||
|
||||
@staticmethod
|
||||
def _get_strategy(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
) -> TenantPluginAutoUpgradeStrategy | None:
|
||||
return session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(
|
||||
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id,
|
||||
TenantPluginAutoUpgradeStrategy.category == category,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_strategy(
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
) -> TenantPluginAutoUpgradeStrategy | None:
|
||||
with session_factory.create_session() as session:
|
||||
return PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
|
||||
|
||||
@staticmethod
|
||||
def get_strategies(tenant_id: str) -> list[TenantPluginAutoUpgradeStrategy]:
|
||||
with session_factory.create_session() as session:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(TenantPluginAutoUpgradeStrategy).where(
|
||||
TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _change_strategy(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
|
||||
upgrade_time_of_day: int,
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
) -> None:
|
||||
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
|
||||
if not exist_strategy:
|
||||
strategy = TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=strategy_setting,
|
||||
upgrade_time_of_day=upgrade_time_of_day,
|
||||
upgrade_mode=upgrade_mode,
|
||||
exclude_plugins=exclude_plugins,
|
||||
include_plugins=include_plugins,
|
||||
)
|
||||
session.add(strategy)
|
||||
else:
|
||||
exist_strategy.strategy_setting = strategy_setting
|
||||
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
|
||||
exist_strategy.upgrade_mode = upgrade_mode
|
||||
exist_strategy.exclude_plugins = exclude_plugins
|
||||
exist_strategy.include_plugins = include_plugins
|
||||
|
||||
@staticmethod
|
||||
def change_strategy(
|
||||
@ -22,64 +299,72 @@ class PluginAutoUpgradeService:
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
category: PluginCategory,
|
||||
) -> bool:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
PluginAutoUpgradeService._change_strategy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
category=category,
|
||||
strategy_setting=strategy_setting,
|
||||
upgrade_time_of_day=upgrade_time_of_day,
|
||||
upgrade_mode=upgrade_mode,
|
||||
exclude_plugins=exclude_plugins,
|
||||
include_plugins=include_plugins,
|
||||
)
|
||||
if not exist_strategy:
|
||||
strategy = TenantPluginAutoUpgradeStrategy(
|
||||
tenant_id=tenant_id,
|
||||
strategy_setting=strategy_setting,
|
||||
upgrade_time_of_day=upgrade_time_of_day,
|
||||
upgrade_mode=upgrade_mode,
|
||||
exclude_plugins=exclude_plugins,
|
||||
include_plugins=include_plugins,
|
||||
)
|
||||
session.add(strategy)
|
||||
else:
|
||||
exist_strategy.strategy_setting = strategy_setting
|
||||
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
|
||||
exist_strategy.upgrade_mode = upgrade_mode
|
||||
exist_strategy.exclude_plugins = exclude_plugins
|
||||
exist_strategy.include_plugins = include_plugins
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
exist_strategy = session.scalar(
|
||||
select(TenantPluginAutoUpgradeStrategy)
|
||||
.where(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
|
||||
.limit(1)
|
||||
def _exclude_plugin(
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
category: PluginCategory,
|
||||
plugin_id: str,
|
||||
) -> None:
|
||||
"""Remove one plugin from automatic updates for a single category strategy."""
|
||||
exist_strategy = PluginAutoUpgradeService._get_strategy(session, tenant_id, category)
|
||||
if not exist_strategy:
|
||||
PluginAutoUpgradeService._change_strategy(
|
||||
session,
|
||||
tenant_id,
|
||||
category,
|
||||
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
0,
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
[plugin_id],
|
||||
[],
|
||||
)
|
||||
if not exist_strategy:
|
||||
# create for this tenant
|
||||
PluginAutoUpgradeService.change_strategy(
|
||||
tenant_id,
|
||||
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
0,
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
[plugin_id],
|
||||
[],
|
||||
)
|
||||
return True
|
||||
else:
|
||||
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
|
||||
if plugin_id not in exist_strategy.exclude_plugins:
|
||||
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
|
||||
new_exclude_plugins.append(plugin_id)
|
||||
exist_strategy.exclude_plugins = new_exclude_plugins
|
||||
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
|
||||
if plugin_id in exist_strategy.include_plugins:
|
||||
new_include_plugins = exist_strategy.include_plugins.copy()
|
||||
new_include_plugins.remove(plugin_id)
|
||||
exist_strategy.include_plugins = new_include_plugins
|
||||
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
|
||||
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||
exist_strategy.exclude_plugins = [plugin_id]
|
||||
else:
|
||||
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
|
||||
# In exclude mode, disabling one plugin means adding it to exclude_plugins.
|
||||
if plugin_id not in exist_strategy.exclude_plugins:
|
||||
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
|
||||
new_exclude_plugins.append(plugin_id)
|
||||
exist_strategy.exclude_plugins = new_exclude_plugins
|
||||
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
|
||||
# In partial mode, disabling one plugin means removing it from include_plugins.
|
||||
if plugin_id in exist_strategy.include_plugins:
|
||||
new_include_plugins = exist_strategy.include_plugins.copy()
|
||||
new_include_plugins.remove(plugin_id)
|
||||
exist_strategy.include_plugins = new_include_plugins
|
||||
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
|
||||
# In all mode, switch to exclude mode so only this plugin is skipped.
|
||||
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||
exist_strategy.exclude_plugins = [plugin_id]
|
||||
|
||||
return True
|
||||
@staticmethod
|
||||
def exclude_plugin(
|
||||
tenant_id: str,
|
||||
plugin_id: str,
|
||||
category: PluginCategory,
|
||||
) -> bool:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
PluginAutoUpgradeService._exclude_plugin(
|
||||
session,
|
||||
tenant_id,
|
||||
category,
|
||||
plugin_id,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@ -22,6 +22,7 @@ class RecommendedAppItemDict(TypedDict):
|
||||
categories: list[str]
|
||||
position: int
|
||||
is_listed: bool
|
||||
can_trial: NotRequired[bool]
|
||||
|
||||
|
||||
class RecommendedAppsResultDict(TypedDict):
|
||||
@ -61,14 +62,47 @@ class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = db.session.scalars(
|
||||
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == language)
|
||||
).all()
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language)
|
||||
|
||||
if len(recommended_apps) == 0:
|
||||
recommended_apps = db.session.scalars(
|
||||
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.language == languages[0])
|
||||
).all()
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0])
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language)
|
||||
|
||||
@classmethod
|
||||
def fetch_learn_dify_apps_from_db(cls, language: str) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Fetch listed recommended apps explicitly marked for the Learn Dify section.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language, is_learn_dify=True)
|
||||
|
||||
if len(recommended_apps) == 0 and language != languages[0]:
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], is_learn_dify=True)
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language)
|
||||
|
||||
@classmethod
|
||||
def _fetch_listed_recommended_apps(
|
||||
cls, language: str, *, is_learn_dify: bool | None = None
|
||||
) -> list[RecommendedApp]:
|
||||
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
|
||||
if is_learn_dify is not None:
|
||||
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
|
||||
|
||||
return db.session.scalars(select(RecommendedApp).where(*filters)).all()
|
||||
|
||||
@classmethod
|
||||
def _format_recommended_apps(
|
||||
cls, recommended_apps: list[RecommendedApp], language: str
|
||||
) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Serialize DB recommended app rows into the Explore list response shape.
|
||||
:param recommended_apps: recommended app rows
|
||||
:param language: language used for category ordering
|
||||
:return:
|
||||
"""
|
||||
|
||||
categories = set()
|
||||
recommended_apps_result: list[RecommendedAppItemDict] = []
|
||||
|
||||
@ -6,6 +6,7 @@ from configs import dify_config
|
||||
from extensions.ext_database import db
|
||||
from models.model import AccountTrialAppRecord, TrialApp
|
||||
from services.feature_service import FeatureService
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
|
||||
|
||||
|
||||
@ -31,13 +32,24 @@ class RecommendedAppService:
|
||||
apps = result["recommended_apps"]
|
||||
for app in apps:
|
||||
app_id = app["app_id"]
|
||||
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
|
||||
if trial_app_model:
|
||||
app["can_trial"] = True
|
||||
else:
|
||||
app["can_trial"] = False
|
||||
app["can_trial"] = cls._can_trial_app(app_id)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_learn_dify_apps(cls, language: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get database-backed recommended apps marked as Learn Dify.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language)
|
||||
|
||||
if FeatureService.get_system_features().enable_trial_app:
|
||||
for app in result["recommended_apps"]:
|
||||
app["can_trial"] = cls._can_trial_app(app["app_id"])
|
||||
|
||||
return {"recommended_apps": result["recommended_apps"]}
|
||||
|
||||
@classmethod
|
||||
def get_recommend_app_detail(cls, app_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
@ -52,11 +64,7 @@ class RecommendedAppService:
|
||||
return None
|
||||
if FeatureService.get_system_features().enable_trial_app:
|
||||
app_id = result["id"]
|
||||
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
|
||||
if trial_app_model:
|
||||
result["can_trial"] = True
|
||||
else:
|
||||
result["can_trial"] = False
|
||||
result["can_trial"] = cls._can_trial_app(app_id)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@ -77,3 +85,8 @@ class RecommendedAppService:
|
||||
else:
|
||||
db.session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _can_trial_app(app_id: str) -> bool:
|
||||
trial_app_model = db.session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
|
||||
return trial_app_model is not None
|
||||
|
||||
@ -7,7 +7,7 @@ import click
|
||||
from celery import shared_task
|
||||
|
||||
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin import PluginInstallation, PluginInstallationSource
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
@ -15,6 +15,7 @@ from models.account import TenantPluginAutoUpgradeStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PluginCategory = TenantPluginAutoUpgradeStrategy.PluginCategory
|
||||
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
|
||||
CACHE_REDIS_KEY_PREFIX = "plugin_autoupgrade_check_task:cached_plugin_snapshot:"
|
||||
CACHE_REDIS_TTL = 60 * 60 # 1 hour
|
||||
@ -72,6 +73,25 @@ def marketplace_batch_fetch_plugin_manifests(
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_category(category: PluginCategory | str | None) -> str | None:
|
||||
if category is None:
|
||||
return None
|
||||
if isinstance(category, PluginCategory):
|
||||
return category.value
|
||||
return str(category)
|
||||
|
||||
|
||||
def _plugin_matches_category(plugin: PluginInstallation, category: str | None) -> bool:
|
||||
"""Return whether an installed plugin should be checked by a category strategy."""
|
||||
if category is None:
|
||||
return True
|
||||
|
||||
declaration = getattr(plugin, "declaration", None)
|
||||
plugin_category = getattr(declaration, "category", None)
|
||||
plugin_category_value = getattr(plugin_category, "value", plugin_category)
|
||||
return plugin_category_value == category
|
||||
|
||||
|
||||
@shared_task(queue="plugin")
|
||||
def process_tenant_plugin_autoupgrade_check_task(
|
||||
tenant_id: str,
|
||||
@ -80,13 +100,15 @@ def process_tenant_plugin_autoupgrade_check_task(
|
||||
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
|
||||
exclude_plugins: list[str],
|
||||
include_plugins: list[str],
|
||||
category: PluginCategory | str | None = None,
|
||||
):
|
||||
try:
|
||||
manager = PluginInstaller()
|
||||
category_value = _normalize_category(category)
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Checking upgradable plugin for tenant: {tenant_id}",
|
||||
f"Checking upgradable plugin for tenant: {tenant_id}, category: {category_value or 'all'}",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
@ -102,7 +124,11 @@ def process_tenant_plugin_autoupgrade_check_task(
|
||||
all_plugins = manager.list_plugins(tenant_id)
|
||||
|
||||
for plugin in all_plugins:
|
||||
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins:
|
||||
if (
|
||||
plugin.source == PluginInstallationSource.Marketplace
|
||||
and plugin.plugin_id in include_plugins
|
||||
and _plugin_matches_category(plugin, category_value)
|
||||
):
|
||||
plugin_ids.append(
|
||||
(
|
||||
plugin.plugin_id,
|
||||
@ -117,7 +143,9 @@ def process_tenant_plugin_autoupgrade_check_task(
|
||||
plugin_ids = [
|
||||
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
|
||||
for plugin in all_plugins
|
||||
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins
|
||||
if plugin.source == PluginInstallationSource.Marketplace
|
||||
and plugin.plugin_id not in exclude_plugins
|
||||
and _plugin_matches_category(plugin, category_value)
|
||||
]
|
||||
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
|
||||
all_plugins = manager.list_plugins(tenant_id)
|
||||
@ -125,6 +153,7 @@ def process_tenant_plugin_autoupgrade_check_task(
|
||||
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
|
||||
for plugin in all_plugins
|
||||
if plugin.source == PluginInstallationSource.Marketplace
|
||||
and _plugin_matches_category(plugin, category_value)
|
||||
]
|
||||
|
||||
if not plugin_ids:
|
||||
|
||||
@ -7,6 +7,8 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_permission_service import PluginPermissionService
|
||||
|
||||
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tenant(flask_req_ctx):
|
||||
@ -71,7 +73,7 @@ class TestPluginPermissionLifecycle:
|
||||
|
||||
class TestPluginAutoUpgradeLifecycle:
|
||||
def test_get_returns_none_for_new_tenant(self, tenant):
|
||||
assert PluginAutoUpgradeService.get_strategy(tenant) is None
|
||||
assert PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY) is None
|
||||
|
||||
def test_change_creates_row(self, tenant):
|
||||
result = PluginAutoUpgradeService.change_strategy(
|
||||
@ -81,10 +83,11 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
|
||||
assert strategy.upgrade_time_of_day == 3
|
||||
@ -97,6 +100,7 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
PluginAutoUpgradeService.change_strategy(
|
||||
tenant,
|
||||
@ -105,9 +109,10 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
|
||||
exclude_plugins=[],
|
||||
include_plugins=["plugin-a"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
|
||||
assert strategy.upgrade_time_of_day == 12
|
||||
@ -115,9 +120,9 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
assert strategy.include_plugins == ["plugin-a"]
|
||||
|
||||
def test_exclude_plugin_creates_strategy_when_none_exists(self, tenant):
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin")
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "my-plugin", PLUGIN_CATEGORY)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||
assert "my-plugin" in strategy.exclude_plugins
|
||||
@ -130,10 +135,11 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["existing"],
|
||||
include_plugins=[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin")
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "new-plugin", PLUGIN_CATEGORY)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert "existing" in strategy.exclude_plugins
|
||||
assert "new-plugin" in strategy.exclude_plugins
|
||||
@ -146,10 +152,11 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["same-plugin"],
|
||||
include_plugins=[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin")
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "same-plugin", PLUGIN_CATEGORY)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert strategy.exclude_plugins.count("same-plugin") == 1
|
||||
|
||||
@ -161,10 +168,11 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
|
||||
exclude_plugins=[],
|
||||
include_plugins=["p1", "p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "p1")
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "p1", PLUGIN_CATEGORY)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert "p1" not in strategy.include_plugins
|
||||
assert "p2" in strategy.include_plugins
|
||||
@ -177,10 +185,11 @@ class TestPluginAutoUpgradeLifecycle:
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin")
|
||||
PluginAutoUpgradeService.exclude_plugin(tenant, "excluded-plugin", PLUGIN_CATEGORY)
|
||||
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant)
|
||||
strategy = PluginAutoUpgradeService.get_strategy(tenant, PLUGIN_CATEGORY)
|
||||
assert strategy is not None
|
||||
assert strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
|
||||
assert "excluded-plugin" in strategy.exclude_plugins
|
||||
|
||||
@ -51,6 +51,7 @@ def _create_recommended_app(
|
||||
categories: list[str] | None = None,
|
||||
language: str = "en-US",
|
||||
is_listed: bool = True,
|
||||
is_learn_dify: bool = False,
|
||||
position: int = 1,
|
||||
) -> RecommendedApp:
|
||||
rec = RecommendedApp(
|
||||
@ -62,6 +63,7 @@ def _create_recommended_app(
|
||||
categories=[category] if categories is None else categories,
|
||||
language=language,
|
||||
is_listed=is_listed,
|
||||
is_learn_dify=is_learn_dify,
|
||||
position=position,
|
||||
)
|
||||
rec.id = str(uuid4())
|
||||
@ -205,6 +207,65 @@ class TestFetchRecommendedAppsFromDb:
|
||||
app_ids = {r["app_id"] for r in result["recommended_apps"]}
|
||||
assert app1.id not in app_ids
|
||||
|
||||
def test_fetch_learn_dify_apps_uses_flag_not_categories(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
|
||||
_create_recommended_app(
|
||||
db_session_with_containers,
|
||||
app_id=learn_dify_app.id,
|
||||
category="workflow",
|
||||
categories=["Workflow"],
|
||||
is_learn_dify=True,
|
||||
)
|
||||
|
||||
category_only_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=category_only_app.id)
|
||||
_create_recommended_app(
|
||||
db_session_with_containers,
|
||||
app_id=category_only_app.id,
|
||||
category="Learn Dify",
|
||||
categories=["Learn Dify"],
|
||||
is_learn_dify=False,
|
||||
)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("en-US")
|
||||
|
||||
app_ids = {r["app_id"] for r in result["recommended_apps"]}
|
||||
assert learn_dify_app.id in app_ids
|
||||
assert category_only_app.id not in app_ids
|
||||
recommended_app = next(item for item in result["recommended_apps"] if item["app_id"] == learn_dify_app.id)
|
||||
assert recommended_app["categories"] == ["Workflow"]
|
||||
|
||||
def test_fetch_learn_dify_apps_falls_back_to_default_language(
|
||||
self,
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers: Session,
|
||||
):
|
||||
tenant_id = str(uuid4())
|
||||
learn_dify_app = _create_app(db_session_with_containers, tenant_id=tenant_id)
|
||||
_create_site(db_session_with_containers, app_id=learn_dify_app.id)
|
||||
_create_recommended_app(
|
||||
db_session_with_containers,
|
||||
app_id=learn_dify_app.id,
|
||||
categories=["Workflow"],
|
||||
is_learn_dify=True,
|
||||
language="en-US",
|
||||
)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db("fr-FR")
|
||||
|
||||
app_ids = {r["app_id"] for r in result["recommended_apps"]}
|
||||
assert learn_dify_app.id in app_ids
|
||||
|
||||
|
||||
class TestFetchRecommendedAppDetailFromDb:
|
||||
def test_returns_none_when_not_listed(self, flask_app_with_containers: Flask, db_session_with_containers: Session):
|
||||
|
||||
@ -31,6 +31,7 @@ def current_user(tenant_id):
|
||||
def installed_app():
|
||||
app = MagicMock()
|
||||
app.id = "ia1"
|
||||
app.app_id = "a1"
|
||||
app.app = MagicMock(id="a1")
|
||||
app.app_owner_tenant_id = "t2"
|
||||
app.is_pinned = False
|
||||
@ -38,6 +39,22 @@ def installed_app():
|
||||
return app
|
||||
|
||||
|
||||
def make_scalars_result(items: list[MagicMock]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = items
|
||||
return result
|
||||
|
||||
|
||||
def make_installed_apps_session(installed_apps: list[MagicMock]) -> MagicMock:
|
||||
session = MagicMock()
|
||||
app_models = [installed_app.app for installed_app in installed_apps if installed_app.app is not None]
|
||||
session.scalars.side_effect = [
|
||||
make_scalars_result(installed_apps),
|
||||
make_scalars_result(app_models),
|
||||
]
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload_patch():
|
||||
def _patch(payload):
|
||||
@ -56,8 +73,7 @@ class TestInstalledAppsListApi:
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app]
|
||||
session = make_installed_apps_session([installed_app])
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -80,8 +96,7 @@ class TestInstalledAppsListApi:
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = []
|
||||
session = make_installed_apps_session([])
|
||||
|
||||
with (
|
||||
app.test_request_context("/?app_id=a1"),
|
||||
@ -103,8 +118,7 @@ class TestInstalledAppsListApi:
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app]
|
||||
session = make_installed_apps_session([installed_app])
|
||||
|
||||
mock_webapp_setting = MagicMock()
|
||||
mock_webapp_setting.access_mode = "restricted"
|
||||
@ -139,8 +153,7 @@ class TestInstalledAppsListApi:
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app]
|
||||
session = make_installed_apps_session([installed_app])
|
||||
|
||||
mock_webapp_setting = MagicMock()
|
||||
mock_webapp_setting.access_mode = "restricted"
|
||||
@ -175,8 +188,7 @@ class TestInstalledAppsListApi:
|
||||
api = module.InstalledAppsListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app]
|
||||
session = make_installed_apps_session([installed_app])
|
||||
|
||||
mock_webapp_setting = MagicMock()
|
||||
mock_webapp_setting.access_mode = "sso_verified"
|
||||
@ -207,10 +219,10 @@ class TestInstalledAppsListApi:
|
||||
method = unwrap(api.get)
|
||||
|
||||
installed_app_with_null = MagicMock()
|
||||
installed_app_with_null.app_id = "a1"
|
||||
installed_app_with_null.app = None
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app_with_null]
|
||||
session = make_installed_apps_session([installed_app_with_null])
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
@ -235,8 +247,7 @@ class TestInstalledAppsListApi:
|
||||
current_user = MagicMock()
|
||||
current_user.current_tenant = None
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [installed_app]
|
||||
session = make_installed_apps_session([installed_app])
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
|
||||
@ -74,6 +74,48 @@ class TestRecommendedAppListApi:
|
||||
assert result == result_data
|
||||
|
||||
|
||||
class TestLearnDifyAppListApi:
|
||||
def test_get_with_language_param(self, app: Flask):
|
||||
api = module.LearnDifyAppListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
result_data = {"recommended_apps": []}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", query_string={"language": "en-US"}),
|
||||
patch.object(module, "current_user", MagicMock(interface_language="fr-FR")),
|
||||
patch.object(
|
||||
module.RecommendedAppService,
|
||||
"get_learn_dify_apps",
|
||||
return_value=result_data,
|
||||
) as service_mock,
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
service_mock.assert_called_once_with("en-US")
|
||||
assert result == result_data
|
||||
|
||||
def test_get_fallback_to_user_language(self, app: Flask):
|
||||
api = module.LearnDifyAppListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
result_data = {"recommended_apps": []}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", query_string={"language": "invalid"}),
|
||||
patch.object(module, "current_user", MagicMock(interface_language="fr-FR")),
|
||||
patch.object(
|
||||
module.RecommendedAppService,
|
||||
"get_learn_dify_apps",
|
||||
return_value=result_data,
|
||||
) as service_mock,
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
service_mock.assert_called_once_with("fr-FR")
|
||||
assert result == result_data
|
||||
|
||||
|
||||
class TestRecommendedAppApi:
|
||||
def test_get_success(self, app: Flask):
|
||||
api = module.RecommendedAppApi()
|
||||
@ -139,3 +181,29 @@ class TestRecommendedAppResponseModels:
|
||||
assert response["recommended_apps"][0]["app_id"] == "app-1"
|
||||
assert response["recommended_apps"][0]["categories"] == ["cat", "other"]
|
||||
assert response["categories"] == ["cat"]
|
||||
|
||||
def test_learn_dify_app_list_response_serialization(self):
|
||||
response = module.LearnDifyAppListResponse.model_validate(
|
||||
{
|
||||
"recommended_apps": [
|
||||
{
|
||||
"app": {
|
||||
"id": "app-1",
|
||||
"name": "App",
|
||||
"mode": "chat",
|
||||
"icon": "icon.png",
|
||||
"icon_type": "emoji",
|
||||
"icon_background": "#fff",
|
||||
},
|
||||
"app_id": "app-1",
|
||||
"description": "desc",
|
||||
"categories": ["Workflow"],
|
||||
"position": 1,
|
||||
"is_listed": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert response["recommended_apps"][0]["app_id"] == "app-1"
|
||||
assert response["recommended_apps"][0]["categories"] == ["Workflow"]
|
||||
|
||||
@ -9,12 +9,13 @@ from werkzeug.exceptions import Forbidden
|
||||
from controllers.console.workspace.plugin import (
|
||||
PluginAssetApi,
|
||||
PluginAutoUpgradeExcludePluginApi,
|
||||
PluginChangeAutoUpgradeApi,
|
||||
PluginChangePermissionApi,
|
||||
PluginChangePreferencesApi,
|
||||
PluginDebuggingKeyApi,
|
||||
PluginDeleteAllInstallTaskItemsApi,
|
||||
PluginDeleteInstallTaskApi,
|
||||
PluginDeleteInstallTaskItemApi,
|
||||
PluginFetchAutoUpgradeApi,
|
||||
PluginFetchDynamicSelectOptionsApi,
|
||||
PluginFetchDynamicSelectOptionsWithCredentialsApi,
|
||||
PluginFetchInstallTaskApi,
|
||||
@ -22,7 +23,6 @@ from controllers.console.workspace.plugin import (
|
||||
PluginFetchManifestApi,
|
||||
PluginFetchMarketplacePkgApi,
|
||||
PluginFetchPermissionApi,
|
||||
PluginFetchPreferencesApi,
|
||||
PluginIconApi,
|
||||
PluginInstallFromGithubApi,
|
||||
PluginInstallFromMarketplaceApi,
|
||||
@ -901,18 +901,15 @@ class TestPluginFetchDynamicSelectOptionsWithCredentialsApi:
|
||||
assert result == ({"code": "plugin_error", "message": "error"}, 400)
|
||||
|
||||
|
||||
class TestPluginChangePreferencesApi:
|
||||
class TestPluginChangeAutoUpgradeApi:
|
||||
def test_success(self, app: Flask):
|
||||
api = PluginChangePreferencesApi()
|
||||
api = PluginChangeAutoUpgradeApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
user = MagicMock(is_admin_or_owner=True)
|
||||
|
||||
payload = {
|
||||
"permission": {
|
||||
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
|
||||
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
|
||||
},
|
||||
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
|
||||
"auto_upgrade": {
|
||||
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
"upgrade_time_of_day": 0,
|
||||
@ -925,24 +922,53 @@ class TestPluginChangePreferencesApi:
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.plugin.current_account_with_tenant", return_value=(user, "t1")),
|
||||
patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=True),
|
||||
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
|
||||
) as change,
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
assert result["success"] is True
|
||||
change.assert_called_once()
|
||||
|
||||
def test_permission_fail(self, app: Flask):
|
||||
api = PluginChangePreferencesApi()
|
||||
def test_success_with_model_category_auto_upgrade(self, app: Flask):
|
||||
api = PluginChangeAutoUpgradeApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
user = MagicMock(is_admin_or_owner=True)
|
||||
|
||||
payload = {
|
||||
"permission": {
|
||||
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
|
||||
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
|
||||
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL.value,
|
||||
"auto_upgrade": {
|
||||
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST,
|
||||
"upgrade_time_of_day": 3600,
|
||||
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
"exclude_plugins": [],
|
||||
"include_plugins": [],
|
||||
},
|
||||
}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.plugin.current_account_with_tenant", return_value=(user, "t1")),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=True
|
||||
) as change,
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
assert result["success"] is True
|
||||
change.assert_called_once()
|
||||
assert change.call_args.kwargs["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
|
||||
|
||||
def test_auto_upgrade_fail(self, app: Flask):
|
||||
api = PluginChangeAutoUpgradeApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
user = MagicMock(is_admin_or_owner=True)
|
||||
|
||||
payload = {
|
||||
"category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value,
|
||||
"auto_upgrade": {
|
||||
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
"upgrade_time_of_day": 0,
|
||||
@ -955,24 +981,20 @@ class TestPluginChangePreferencesApi:
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
patch("controllers.console.workspace.plugin.current_account_with_tenant", return_value=(user, "t1")),
|
||||
patch("controllers.console.workspace.plugin.PluginPermissionService.change_permission", return_value=False),
|
||||
patch("controllers.console.workspace.plugin.PluginAutoUpgradeService.change_strategy", return_value=False),
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
class TestPluginFetchPreferencesApi:
|
||||
class TestPluginFetchAutoUpgradeApi:
|
||||
def test_success(self, app: Flask):
|
||||
api = PluginFetchPreferencesApi()
|
||||
api = PluginFetchAutoUpgradeApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
permission = MagicMock(
|
||||
install_permission=TenantPluginPermission.InstallPermission.EVERYONE,
|
||||
debug_permission=TenantPluginPermission.DebugPermission.EVERYONE,
|
||||
)
|
||||
|
||||
auto_upgrade = MagicMock(
|
||||
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=1,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
@ -981,19 +1003,17 @@ class TestPluginFetchPreferencesApi:
|
||||
)
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
app.test_request_context(f"/?category={TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}"),
|
||||
patch("controllers.console.workspace.plugin.current_account_with_tenant", return_value=(None, "t1")),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginPermissionService.get_permission", return_value=permission
|
||||
),
|
||||
patch(
|
||||
"controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy", return_value=auto_upgrade
|
||||
"controllers.console.workspace.plugin.PluginAutoUpgradeService.get_strategy",
|
||||
return_value=auto_upgrade,
|
||||
),
|
||||
):
|
||||
result = method(api)
|
||||
|
||||
assert "permission" in result
|
||||
assert "auto_upgrade" in result
|
||||
assert result["category"] == TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
|
||||
assert result["auto_upgrade"]["upgrade_time_of_day"] == 1
|
||||
|
||||
|
||||
class TestPluginAutoUpgradeExcludePluginApi:
|
||||
@ -1001,7 +1021,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
|
||||
api = PluginAutoUpgradeExcludePluginApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"plugin_id": "p"}
|
||||
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
@ -1016,7 +1036,7 @@ class TestPluginAutoUpgradeExcludePluginApi:
|
||||
api = PluginAutoUpgradeExcludePluginApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"plugin_id": "p"}
|
||||
payload = {"plugin_id": "p", "category": TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL.value}
|
||||
|
||||
with (
|
||||
app.test_request_context("/", json=payload),
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
|
||||
MODULE = "services.plugin.plugin_auto_upgrade_service"
|
||||
PLUGIN_CATEGORY = TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL
|
||||
|
||||
|
||||
def _patched_session():
|
||||
@ -25,7 +27,7 @@ class TestGetStrategy:
|
||||
with p1:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1")
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY)
|
||||
|
||||
assert result is strategy
|
||||
|
||||
@ -36,7 +38,7 @@ class TestGetStrategy:
|
||||
with p1:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.get_strategy("t1")
|
||||
result = PluginAutoUpgradeService.get_strategy("t1", PLUGIN_CATEGORY)
|
||||
|
||||
assert result is None
|
||||
|
||||
@ -57,6 +59,7 @@ class TestChangeStrategy:
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
[],
|
||||
[],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
@ -77,6 +80,7 @@ class TestChangeStrategy:
|
||||
TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL,
|
||||
["p1"],
|
||||
["p2"],
|
||||
category=PLUGIN_CATEGORY,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
@ -96,17 +100,19 @@ class TestExcludePlugin:
|
||||
p1,
|
||||
patch(f"{MODULE}.select"),
|
||||
patch(f"{MODULE}.TenantPluginAutoUpgradeStrategy") as strat_cls,
|
||||
patch(f"{MODULE}.PluginAutoUpgradeService.change_strategy") as cs,
|
||||
):
|
||||
strat_cls.StrategySetting.FIX_ONLY = "fix_only"
|
||||
strat_cls.UpgradeMode.EXCLUDE = "exclude"
|
||||
cs.return_value = True
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "plugin-1")
|
||||
result = PluginAutoUpgradeService.exclude_plugin(
|
||||
"t1",
|
||||
"plugin-1",
|
||||
PLUGIN_CATEGORY,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
cs.assert_called_once()
|
||||
session.add.assert_called_once()
|
||||
|
||||
def test_appends_to_exclude_list_in_exclude_mode(self):
|
||||
p1, session = _patched_session()
|
||||
@ -121,7 +127,7 @@ class TestExcludePlugin:
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new")
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p-new", PLUGIN_CATEGORY)
|
||||
|
||||
assert result is True
|
||||
assert existing.exclude_plugins == ["p-existing", "p-new"]
|
||||
@ -139,7 +145,7 @@ class TestExcludePlugin:
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1")
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
|
||||
|
||||
assert result is True
|
||||
assert existing.include_plugins == ["p2"]
|
||||
@ -156,7 +162,7 @@ class TestExcludePlugin:
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1")
|
||||
result = PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
|
||||
|
||||
assert result is True
|
||||
assert existing.upgrade_mode == "exclude"
|
||||
@ -175,6 +181,101 @@ class TestExcludePlugin:
|
||||
strat_cls.UpgradeMode.ALL = "all"
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
PluginAutoUpgradeService.exclude_plugin("t1", "p1")
|
||||
PluginAutoUpgradeService.exclude_plugin("t1", "p1", PLUGIN_CATEGORY)
|
||||
|
||||
assert existing.exclude_plugins == ["p1"]
|
||||
|
||||
|
||||
class TestBackfillStrategyCategories:
|
||||
def test_creates_default_missing_categories_without_fetching_daemon(self):
|
||||
p1, session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=[],
|
||||
include_plugins=[],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy]
|
||||
installer = MagicMock()
|
||||
|
||||
with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
|
||||
expected_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 1
|
||||
assert result.normalized is False
|
||||
installer.list_plugins.assert_not_called()
|
||||
assert tool_strategy.upgrade_time_of_day == expected_time
|
||||
created_strategies = [call.args[0] for call in session.add.call_args_list]
|
||||
model_strategy = next(
|
||||
strategy
|
||||
for strategy in created_strategies
|
||||
if strategy.category == TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL
|
||||
)
|
||||
assert model_strategy.strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST
|
||||
assert model_strategy.upgrade_time_of_day == expected_time
|
||||
|
||||
def test_default_upgrade_time_is_aligned_to_fifteen_minutes(self):
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
default_time = PluginAutoUpgradeService.default_upgrade_time_of_day("t1")
|
||||
|
||||
assert default_time % (15 * 60) == 0
|
||||
assert 0 <= default_time < 24 * 60 * 60
|
||||
|
||||
def test_creates_missing_categories_and_splits_known_plugins(self):
|
||||
p1, session = _patched_session()
|
||||
tool_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
model_strategy = SimpleNamespace(
|
||||
category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL,
|
||||
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
|
||||
upgrade_time_of_day=0,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
|
||||
exclude_plugins=["tool-plugin", "model-plugin", "unknown-plugin"],
|
||||
include_plugins=["model-plugin", "tool-plugin"],
|
||||
)
|
||||
session.scalars.return_value.all.return_value = [tool_strategy, model_strategy]
|
||||
|
||||
installed_plugins = [
|
||||
SimpleNamespace(
|
||||
plugin_id="tool-plugin",
|
||||
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.TOOL),
|
||||
),
|
||||
SimpleNamespace(
|
||||
plugin_id="model-plugin",
|
||||
declaration=SimpleNamespace(category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL),
|
||||
),
|
||||
]
|
||||
installer = MagicMock()
|
||||
installer.list_plugins.return_value = installed_plugins
|
||||
|
||||
with p1, patch(f"{MODULE}.PluginInstaller", return_value=installer), patch(f"{MODULE}.logger") as logger:
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories("t1")
|
||||
|
||||
assert result.created_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
|
||||
assert result.normalized is True
|
||||
assert session.add.call_count == len(TenantPluginAutoUpgradeStrategy.PluginCategory) - 2
|
||||
assert tool_strategy.exclude_plugins == ["tool-plugin"]
|
||||
assert tool_strategy.include_plugins == ["tool-plugin"]
|
||||
assert model_strategy.exclude_plugins == ["model-plugin"]
|
||||
assert model_strategy.include_plugins == ["model-plugin"]
|
||||
logger.warning.assert_called_once_with(
|
||||
"Skipped unknown plugin IDs while backfilling plugin auto-upgrade strategies: "
|
||||
"tenant_id=%s, field=%s, plugin_ids=%s",
|
||||
"t1",
|
||||
"exclude_plugins",
|
||||
["unknown-plugin"],
|
||||
)
|
||||
|
||||
@ -7,6 +7,7 @@ returns None causes a TypeError / KeyError in self-hosted mode.
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from services import recommended_app_service as service_module
|
||||
from services.recommended_app_service import RecommendedAppService
|
||||
|
||||
|
||||
@ -44,3 +45,40 @@ class TestGetRecommendAppDetailNullCheck:
|
||||
|
||||
assert result is None
|
||||
mock_instance.get_recommend_app_detail.assert_called_once_with("nonexistent")
|
||||
|
||||
|
||||
class TestGetLearnDifyApps:
|
||||
@patch("services.recommended_app_service.FeatureService", autospec=True)
|
||||
@patch("services.recommended_app_service.DatabaseRecommendAppRetrieval", autospec=True)
|
||||
def test_returns_database_learn_dify_apps_without_remote_factory(
|
||||
self, mock_database_retrieval, mock_feature_service
|
||||
):
|
||||
expected_app = {"app_id": "app-1", "categories": ["Workflow"]}
|
||||
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
|
||||
"recommended_apps": [expected_app],
|
||||
"categories": ["Workflow"],
|
||||
}
|
||||
mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=False)
|
||||
|
||||
with patch.object(service_module.RecommendAppRetrievalFactory, "get_recommend_app_factory") as factory_mock:
|
||||
result = RecommendedAppService.get_learn_dify_apps("en-US")
|
||||
|
||||
assert result == {"recommended_apps": [expected_app]}
|
||||
mock_database_retrieval.fetch_learn_dify_apps_from_db.assert_called_once_with("en-US")
|
||||
factory_mock.assert_not_called()
|
||||
|
||||
@patch("services.recommended_app_service.FeatureService", autospec=True)
|
||||
@patch("services.recommended_app_service.DatabaseRecommendAppRetrieval", autospec=True)
|
||||
def test_sets_can_trial_when_trial_feature_enabled(self, mock_database_retrieval, mock_feature_service):
|
||||
app = {"app_id": "app-1", "categories": ["Workflow"]}
|
||||
mock_database_retrieval.fetch_learn_dify_apps_from_db.return_value = {
|
||||
"recommended_apps": [app],
|
||||
"categories": ["Workflow"],
|
||||
}
|
||||
mock_feature_service.get_system_features.return_value = SimpleNamespace(enable_trial_app=True)
|
||||
|
||||
with patch.object(RecommendedAppService, "_can_trial_app", return_value=True) as can_trial_mock:
|
||||
result = RecommendedAppService.get_learn_dify_apps("en-US")
|
||||
|
||||
assert result["recommended_apps"][0]["can_trial"] is True
|
||||
can_trial_mock.assert_called_once_with("app-1")
|
||||
|
||||
@ -4,19 +4,25 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
|
||||
MODULE = "tasks.process_tenant_plugin_autoupgrade_check_task"
|
||||
|
||||
|
||||
def _make_plugin(plugin_id: str, version: str, source=PluginInstallationSource.Marketplace):
|
||||
def _make_plugin(
|
||||
plugin_id: str,
|
||||
version: str,
|
||||
source=PluginInstallationSource.Marketplace,
|
||||
category: PluginCategory = PluginCategory.Tool,
|
||||
):
|
||||
"""Build a minimal stand-in for a PluginInstallation entry returned by manager.list_plugins."""
|
||||
return SimpleNamespace(
|
||||
plugin_id=plugin_id,
|
||||
version=version,
|
||||
plugin_unique_identifier=f"{plugin_id}:{version}@deadbeef",
|
||||
source=source,
|
||||
declaration=SimpleNamespace(category=category),
|
||||
)
|
||||
|
||||
|
||||
@ -39,6 +45,7 @@ def _run_task(
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
exclude_plugins=None,
|
||||
include_plugins=None,
|
||||
category=None,
|
||||
):
|
||||
"""
|
||||
Execute the celery task synchronously with mocks for the plugin manager,
|
||||
@ -72,6 +79,7 @@ def _run_task(
|
||||
upgrade_mode,
|
||||
exclude_plugins or [],
|
||||
include_plugins or [],
|
||||
category,
|
||||
)
|
||||
|
||||
return upgrade_mock, upgrade_calls
|
||||
@ -246,6 +254,26 @@ class TestUpgradeMode:
|
||||
assert upgrade_mock.call_count == 1
|
||||
assert calls[0][1] == plugins[0].plugin_unique_identifier
|
||||
|
||||
def test_category_strategy_only_upgrades_matching_category(self):
|
||||
plugins = [
|
||||
_make_plugin("acme/model-provider", "1.0.0", category=PluginCategory.Model),
|
||||
_make_plugin("acme/tool-provider", "1.0.0", category=PluginCategory.Tool),
|
||||
]
|
||||
manifests = [
|
||||
_make_manifest("acme/model-provider", "1.0.1"),
|
||||
_make_manifest("acme/tool-provider", "1.0.1"),
|
||||
]
|
||||
|
||||
upgrade_mock, calls = _run_task(
|
||||
plugins=plugins,
|
||||
manifests=manifests,
|
||||
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL,
|
||||
category=TenantPluginAutoUpgradeStrategy.PluginCategory.MODEL,
|
||||
)
|
||||
|
||||
upgrade_mock.assert_called_once()
|
||||
assert calls[0][1] == plugins[0].plugin_unique_identifier
|
||||
|
||||
|
||||
class TestErrorIsolation:
|
||||
def test_one_plugin_failure_does_not_block_others(self):
|
||||
|
||||
@ -1,202 +1,131 @@
|
||||
import type { Key, Store } from '../store/store.js'
|
||||
import type { AccountContext } from './hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts.js'
|
||||
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
describe('HostsBundleSchema', () => {
|
||||
it('parses a minimal logged-out bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({})
|
||||
expect(parsed.current_host).toBe('')
|
||||
expect(parsed.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
it('parses a logged-in keychain bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
expect(parsed.token_storage).toBe('keychain')
|
||||
expect(parsed.tokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
it('parses a logged-in file bundle with bearer', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_xxx' },
|
||||
})
|
||||
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
it('keeps available_workspaces when provided', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
available_workspaces: [
|
||||
{ id: 'a', name: 'A', role: 'owner' },
|
||||
{ id: 'b', name: 'B', role: 'member' },
|
||||
],
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
expect(parsed.available_workspaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields on parse', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
future_field: 42,
|
||||
token_storage: 'file',
|
||||
})
|
||||
expect(parsed.current_host).toBe('cloud.dify.ai')
|
||||
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
describe('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns an empty registry when nothing saved', () => {
|
||||
const reg = Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
it('returns undefined when nothing was saved', () => {
|
||||
expect(loadHosts()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips a populated registry', () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
const loaded = Registry.load()
|
||||
it('round-trips a fully-populated bundle', () => {
|
||||
saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(loaded?.scheme).toBe('https')
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.workspace?.id).toBe('ws-1')
|
||||
expect(loaded?.available_workspaces).toHaveLength(2)
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
expect(loaded?.token_id).toBe('tok_xyz')
|
||||
})
|
||||
|
||||
it('round-trips a file-mode bundle with bearer token', () => {
|
||||
saveHosts({
|
||||
current_host: 'self.example.com',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(loaded?.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('overwrites previous bundle on save', () => {
|
||||
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
|
||||
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('new.example.com')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('rejects invalid input at save time', () => {
|
||||
expect(() => saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'cloud',
|
||||
} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { Store } from '../store/store.js'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
import { getHostStore, tokenKey } from '../store/manager.js'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
@ -27,152 +25,42 @@ export const ExternalSubjectSchema = z.object({
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
export const TokensSchema = z.object({
|
||||
bearer: z.string(),
|
||||
})
|
||||
export type Tokens = z.infer<typeof TokensSchema>
|
||||
|
||||
export const HostsBundleSchema = z.object({
|
||||
current_host: z.string().default(''),
|
||||
scheme: z.string().optional(),
|
||||
account: AccountSchema.optional(),
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
available_workspaces: z.array(WorkspaceSchema).optional(),
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
tokens: TokensSchema.optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
export type HostsBundle = z.infer<typeof HostsBundleSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
export function loadHosts(): HostsBundle | undefined {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return undefined
|
||||
return HostsBundleSchema.parse(raw)
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
export function saveHosts(bundle: HostsBundle): void {
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
getHostStore().setTyped(validated)
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static load(): Registry {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
const email = entry.current_account
|
||||
if (email === undefined || email === '')
|
||||
return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
this.save()
|
||||
}
|
||||
|
||||
save(): void {
|
||||
getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
export function clearLocal(bundle: HostsBundle, store: Store): void {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
store.unset(tokenKey(bundle.current_host, accountId))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
getHostStore().rm()
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../cache/app-info.js'
|
||||
import type { Command } from '../../framework/command.js'
|
||||
import type { Store } from '../../store/store.js'
|
||||
import type { IOStreams } from '../../sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
|
||||
import { notLoggedInError, Registry } from '../../auth/hosts.js'
|
||||
import { loadHosts } from '../../auth/hosts.js'
|
||||
import { loadAppInfoCache } from '../../cache/app-info.js'
|
||||
import { loadNudgeStore } from '../../cache/nudge-store.js'
|
||||
import { getEnv } from '../../env/registry.js'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { formatErrorForCli } from '../../errors/format.js'
|
||||
import { createClient } from '../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../store/manager.js'
|
||||
import { realStreams } from '../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../util/host.js'
|
||||
import { versionInfo } from '../../version/info.js'
|
||||
@ -19,9 +19,7 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
|
||||
import { resolveRetryAttempts } from './global-flags.js'
|
||||
|
||||
export type AuthedContext = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -39,30 +37,28 @@ export async function buildAuthedContext(
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
const bundle = loadHosts()
|
||||
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
const err = new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createClient({ host, bearer, retryAttempts })
|
||||
const host = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({
|
||||
flag: opts.retryFlag,
|
||||
env: getEnv,
|
||||
})
|
||||
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
return { bundle, http, host, io, cache }
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { ActiveContext } from '../../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../../store/store.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import { Registry } from '../../../../auth/hosts.js'
|
||||
import { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../../store/dir.js'
|
||||
import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
|
||||
import { tokenKey } from '../../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../../sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
@ -30,21 +30,20 @@ class MemStore implements Store {
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: tokenId,
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
reg.setAccount(email)
|
||||
const active = reg.resolveActive()!
|
||||
return { reg, active }
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDevicesList', () => {
|
||||
@ -59,7 +58,7 @@ describe('runDevicesList', () => {
|
||||
it('table: marks current with *', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('DEVICE')
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
@ -72,12 +71,20 @@ describe('runDevicesList', () => {
|
||||
it('json: emits PaginationEnvelope unchanged', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.page).toBe(1)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
expect((parsed.data as unknown[]).length).toBe(3)
|
||||
})
|
||||
|
||||
it('not-logged-in: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesList({ io, bundle: undefined, http }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runDevicesRevoke', () => {
|
||||
@ -102,12 +109,12 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact device_label: revokes one + leaves local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -115,30 +122,30 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact id: revokes one', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: unique match revokes', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: ambiguous throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -146,10 +153,10 @@ describe('runDevicesRevoke', () => {
|
||||
it('no match throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -157,33 +164,31 @@ describe('runDevicesRevoke', () => {
|
||||
it('--all: revokes everything except current', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, all: true })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
it('revoking current session clears token and removes context from registry', async () => {
|
||||
it('revoking current id clears local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
const saved = Registry.load()
|
||||
expect(saved?.hosts[mock.url]).toBeUndefined()
|
||||
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry } from '../../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { Store } from '../../../../store/store.js'
|
||||
import type { IOStreams } from '../../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
import { getTokenStore } from '../../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
|
||||
import { runWithSpinner } from '../../../../sys/io/spinner.js'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly tokenId: string
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
@ -21,6 +23,7 @@ export type DevicesListOptions = {
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -35,7 +38,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
@ -69,10 +72,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -80,6 +83,7 @@ export type DevicesRevokeOptions = {
|
||||
|
||||
export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const b = requireLogin(opts.bundle)
|
||||
if (!opts.all && (opts.target === undefined || opts.target === '')) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
@ -90,7 +94,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '')
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
@ -99,12 +103,25 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit)
|
||||
opts.reg.forget(opts.active, opts.store)
|
||||
if (selfHit) {
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(b, tokens)
|
||||
}
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
|
||||
function requireLogin(b: HostsBundle | undefined): HostsBundle {
|
||||
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export type PickResult = {
|
||||
ids: readonly string[]
|
||||
selfHit: boolean
|
||||
|
||||
@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
tokenId: ctx.active.ctx.token_id ?? '',
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
|
||||
@ -26,9 +26,7 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runDevicesRevoke({
|
||||
io: ctx.io,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
store: ctx.store,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
|
||||
@ -59,7 +59,7 @@ describe('runLogin', () => {
|
||||
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -70,17 +70,16 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(bundle.account?.email).toBe('tester@dify.ai')
|
||||
expect(bundle.workspace?.id).toBe('ws-1')
|
||||
expect(bundle.available_workspaces).toHaveLength(2)
|
||||
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
|
||||
expect(stored).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
expect(hostsRaw).toContain('tester@dify.ai')
|
||||
expect(hostsRaw).not.toContain('dfoa_test')
|
||||
expect(hostsRaw).not.toContain('bearer')
|
||||
|
||||
expect(io.outBuf()).toContain('Logged in to')
|
||||
expect(io.outBuf()).toContain('tester@dify.ai')
|
||||
@ -92,7 +91,7 @@ describe('runLogin', () => {
|
||||
mock.setScenario('sso')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -103,11 +102,12 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoe_test')
|
||||
expect(bundle.account).toBeUndefined()
|
||||
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
|
||||
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
|
||||
expect(stored).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
@ -148,24 +148,6 @@ describe('runLogin', () => {
|
||||
})).rejects.toThrow(/expired/)
|
||||
})
|
||||
|
||||
it('rejects login when the account has no email', async () => {
|
||||
mock.setScenario('no-email')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
insecure: true,
|
||||
deviceLabel: 'difyctl on test',
|
||||
api: new DeviceFlowApi(createClient({ host: mock.url })),
|
||||
store: { store, mode: 'file' },
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})).rejects.toThrow(/no email/i)
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects http:// host without --insecure', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
|
||||
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { StorageMode, Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
|
||||
@ -7,13 +7,10 @@ import type { Clock } from './device-flow.js'
|
||||
import * as os from 'node:os'
|
||||
import * as readline from 'node:readline'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { startSpinner } from '../../../sys/io/spinner.js'
|
||||
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
|
||||
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
|
||||
import { awaitAuthorization, realClock } from './device-flow.js'
|
||||
@ -31,7 +28,7 @@ export type LoginOptions = {
|
||||
readonly clock?: Clock
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const insecure = opts.insecure ?? false
|
||||
|
||||
@ -59,44 +56,22 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
|
||||
}
|
||||
|
||||
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
|
||||
let success: PollSuccess
|
||||
try {
|
||||
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
}
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.save()
|
||||
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
|
||||
saveHosts(bundle)
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return reg
|
||||
return bundle
|
||||
}
|
||||
|
||||
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
|
||||
let raw = opts.host?.trim() ?? ''
|
||||
if (raw === '') {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--host is required (no TTY)',
|
||||
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
|
||||
})
|
||||
}
|
||||
if (raw === '')
|
||||
raw = await promptHost(opts.io)
|
||||
}
|
||||
return resolveHost({ raw, insecure })
|
||||
}
|
||||
|
||||
@ -147,43 +122,50 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
|
||||
return s.workspaces?.find(w => w.id === s.default_workspace_id)
|
||||
}
|
||||
|
||||
function accountEmail(s: PollSuccess): string {
|
||||
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
|
||||
if (email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'account has no email; cannot store credential',
|
||||
hint: 'this Dify instance returned no email for the signed-in subject',
|
||||
})
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const ctx: AccountContext = {
|
||||
account: s.account
|
||||
? { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
: { id: '', email: '', name: '' },
|
||||
token_id: s.token_id,
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function applyScheme(reg: Registry, display: string, host: string): void {
|
||||
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
|
||||
const display = bareHost(host)
|
||||
let scheme: string | undefined
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
reg.setScheme(display, u.protocol.replace(':', ''))
|
||||
scheme = u.protocol.replace(':', '')
|
||||
}
|
||||
catch { /* keep scheme unset */ }
|
||||
catch { /* keep undefined */ }
|
||||
|
||||
const bundle: HostsBundle = {
|
||||
current_host: display,
|
||||
scheme,
|
||||
token_storage: mode,
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
}
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
bundle.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
}))
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
function accountKey(b: HostsBundle): string {
|
||||
if (b.account?.id !== undefined && b.account.id !== '')
|
||||
return b.account.id
|
||||
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
|
||||
return b.external_subject.email
|
||||
return 'default'
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../../util/host.js'
|
||||
@ -17,21 +16,21 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const io = realStreams()
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
const bundle = loadHosts()
|
||||
|
||||
let http: KyInstance | undefined
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
if (bearer !== '') {
|
||||
http = createClient({ host: hostWithScheme(active.host, active.scheme), bearer, retryAttempts: 0 })
|
||||
}
|
||||
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
|
||||
http = createClient({
|
||||
host: hostWithScheme(bundle.current_host, bundle.scheme),
|
||||
bearer: bundle.tokens.bearer,
|
||||
retryAttempts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, reg, http }),
|
||||
() => runLogout({ io, bundle, http }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,64 +1,145 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { tokenKey } from '../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureBundle(host: string): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seed(store: MemStore) {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
it('happy: revokes server side, clears local store + hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
seed(store)
|
||||
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
expect(io.errBuf()).toBe('')
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/i)
|
||||
it('not-logged-in: throws BaseError', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('hosts.yml absent: still completes locally + emits success', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
expect(io.errBuf()).toContain('server revoke failed')
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
bundle.tokens = { bearer: 'dfp_personal_token' }
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.errBuf()).toBe('')
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves unrelated files in configDir', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
saveHosts(bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,46 +1,54 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../api/account-sessions.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { clearLocal } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
|
||||
export type LogoutOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = opts.reg
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
let revokeWarning = ''
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
|
||||
try {
|
||||
await new AccountSessionsClient(opts.http).revokeSelf()
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
await sessions.revokeSelf()
|
||||
}
|
||||
catch (err) {
|
||||
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
|
||||
}
|
||||
}
|
||||
|
||||
reg.forget(active, store)
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(bundle, tokens)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -20,7 +20,7 @@ export default class Status extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const reg = Registry.load()
|
||||
await runStatus({ io: realStreams(), reg, verbose: flags.verbose, json: flags.json })
|
||||
const bundle = loadHosts()
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,65 +1,49 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runStatus } from './status.js'
|
||||
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'keychain',
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'tester@dify.ai',
|
||||
accounts: {
|
||||
'tester@dify.ai': {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: 'tok-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
function ssoBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'sso@dify.ai',
|
||||
accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { id: '', email: '', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-sso-1',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runStatus', () => {
|
||||
it('logged-out: prints message + throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, reg: Registry.empty() })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
expect(io.outBuf()).toContain('Not logged in')
|
||||
})
|
||||
|
||||
it('logged-out json: emits {logged_in: false}', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, reg: Registry.empty(), json: true })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
|
||||
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
|
||||
})
|
||||
|
||||
it('account: human compact', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg() })
|
||||
await runStatus({ io, bundle: accountBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
|
||||
expect(out).toContain('Workspace: Default')
|
||||
@ -68,7 +52,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account verbose: shows ids + storage + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg(), verbose: true })
|
||||
await runStatus({ io, bundle: accountBundle(), verbose: true })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('Account:')
|
||||
@ -76,12 +60,11 @@ describe('runStatus', () => {
|
||||
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
|
||||
expect(out).toContain('Available: 2 workspaces')
|
||||
expect(out).toContain('Storage: keychain')
|
||||
expect(out).toContain('Contexts:')
|
||||
})
|
||||
|
||||
it('sso: human compact mentions issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: ssoReg() })
|
||||
await runStatus({ io, bundle: ssoBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
|
||||
expect(out).toContain('apps:run')
|
||||
@ -89,7 +72,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account json: matches schema with workspace + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: accountReg(), json: true })
|
||||
await runStatus({ io, bundle: accountBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.host).toBe('cloud.dify.ai')
|
||||
expect(parsed.logged_in).toBe(true)
|
||||
@ -101,7 +84,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, reg: ssoReg(), json: true })
|
||||
await runStatus({ io, bundle: ssoBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.subject_type).toBe('external_sso')
|
||||
expect(parsed.subject_email).toBe('sso@dify.ai')
|
||||
|
||||
@ -1,94 +1,91 @@
|
||||
import type { AccountContext, Registry } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type StatusOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly verbose?: boolean
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runStatus(opts: StatusOptions): Promise<void> {
|
||||
const reg = opts.reg
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined) {
|
||||
if (opts.json === true)
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
|
||||
else
|
||||
}
|
||||
else {
|
||||
opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n')
|
||||
}
|
||||
throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' })
|
||||
}
|
||||
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${renderJson(active.host, active.ctx, reg.token_storage)}\n`)
|
||||
opts.io.out.write(`${renderJson(bundle)}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(renderHuman(active.host, active.ctx, reg.token_storage, opts.verbose ?? false))
|
||||
if (opts.verbose === true)
|
||||
opts.io.out.write(renderContexts(reg))
|
||||
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
|
||||
}
|
||||
|
||||
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
|
||||
function renderHuman(b: HostsBundle, verbose: boolean): string {
|
||||
const lines: string[] = []
|
||||
const sub = ctx.external_subject
|
||||
if (!verbose) {
|
||||
if (sub !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(sub.issuer !== ''
|
||||
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${host} as ${sub.email} (via SSO)`)
|
||||
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
|
||||
lines.push(' Scope: apps:run')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
lines.push(`Logged in to ${host} as ${ctx.account.email} (${ctx.account.name})`)
|
||||
if (ctx.workspace?.name !== undefined && ctx.workspace.name !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name}`)
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
|
||||
if (b.workspace?.name !== undefined && b.workspace.name !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name}`)
|
||||
lines.push(' Session: Dify account — full access')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
if (sub !== undefined) {
|
||||
lines.push(host)
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(b.current_host)
|
||||
lines.push(sub.issuer !== ''
|
||||
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
|
||||
: ` Subject: ${sub.email} (external SSO)`)
|
||||
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
|
||||
lines.push(` Storage: ${storage}`)
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
lines.push(host)
|
||||
lines.push(` Account: ${ctx.account.email} (${ctx.account.name}, ${ctx.account.id ?? ''})`)
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name} (${ctx.workspace.id}, role: ${ctx.workspace.role})`)
|
||||
lines.push(` Available: ${ctx.available_workspaces?.length ?? 0} workspaces`)
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(b.current_host)
|
||||
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
|
||||
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(' Session: Dify account — full access (scope: full)')
|
||||
lines.push(` Storage: ${storage}`)
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderContexts(reg: Registry): string {
|
||||
const lines = ['Contexts:']
|
||||
for (const [host, entry] of Object.entries(reg.hosts)) {
|
||||
for (const email of Object.keys(entry.accounts)) {
|
||||
const isActive = reg.current_host === host && entry.current_account === email
|
||||
lines.push(` ${isActive ? '*' : ' '} ${host} ${email}`)
|
||||
}
|
||||
function renderJson(b: HostsBundle): string {
|
||||
const out: Record<string, unknown> = {
|
||||
host: b.current_host,
|
||||
logged_in: true,
|
||||
storage: b.token_storage,
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(host: string, ctx: AccountContext, storage: string): string {
|
||||
const out: Record<string, unknown> = { host, logged_in: true, storage }
|
||||
if (ctx.external_subject !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = ctx.external_subject.email
|
||||
out.subject_issuer = ctx.external_subject.issuer
|
||||
out.subject_email = b.external_subject.email
|
||||
out.subject_issuer = b.external_subject.issuer
|
||||
}
|
||||
else {
|
||||
out.account = { id: ctx.account.id ?? '', email: ctx.account.email, name: ctx.account.name }
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
out.workspace = { id: ctx.workspace.id, name: ctx.workspace.name, role: ctx.workspace.role }
|
||||
out.available_workspaces_count = ctx.available_workspaces?.length ?? 0
|
||||
else if (b.account !== undefined) {
|
||||
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
|
||||
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
|
||||
}
|
||||
out.available_workspaces_count = b.available_workspaces?.length ?? 0
|
||||
}
|
||||
return JSON.stringify(out, null, 2)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const reg = Registry.load()
|
||||
await runWhoami({ io: realStreams(), reg, json: flags.json })
|
||||
const bundle = loadHosts()
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,82 +1,68 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runWhoami } from './whoami.js'
|
||||
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
|
||||
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { email: 'sso@dify.ai', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
} } },
|
||||
})
|
||||
token_storage: 'keychain',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runWhoami', () => {
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
|
||||
})
|
||||
|
||||
it('prints email + name for an account', async () => {
|
||||
it('logged-out: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toContain('a@b.c')
|
||||
expect(io.outBuf()).toContain('Ann')
|
||||
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('account human: emits "email (name)"', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
|
||||
await runWhoami({ io, bundle: accountBundle() })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
|
||||
})
|
||||
|
||||
it('account human, no name: emits email only', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = accountReg()
|
||||
reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = ''
|
||||
await runWhoami({ io, reg })
|
||||
expect(io.outBuf()).toBe('a@b.c\n')
|
||||
})
|
||||
|
||||
it('emits JSON when --json', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' })
|
||||
const b = accountBundle()
|
||||
b.account!.name = ''
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai\n')
|
||||
})
|
||||
|
||||
it('account json: emits {id, email, name}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
await runWhoami({ io, bundle: accountBundle(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
id: 'acct-1',
|
||||
email: 'a@b.c',
|
||||
name: 'Ann',
|
||||
email: 'tester@dify.ai',
|
||||
name: 'Test Tester',
|
||||
})
|
||||
})
|
||||
|
||||
it('sso human: emits email + issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg() })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
|
||||
})
|
||||
|
||||
it('sso json: emits {subject_type, email, issuer}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg(), json: true })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b, json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
subject_type: 'external_sso',
|
||||
email: 'sso@dify.ai',
|
||||
|
||||
@ -1,31 +1,46 @@
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type WhoamiOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
|
||||
const active = opts.reg.requireActive()
|
||||
const b = opts.bundle
|
||||
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
const sub = active.ctx.external_subject
|
||||
if (sub !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
|
||||
opts.io.out.write(`${JSON.stringify({
|
||||
subject_type: 'external_sso',
|
||||
email: b.external_subject.email,
|
||||
issuer: b.external_subject.issuer,
|
||||
})}\n`)
|
||||
return
|
||||
}
|
||||
const sub = b.external_subject
|
||||
opts.io.out.write(sub.issuer !== ''
|
||||
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
|
||||
: `${sub.email} (external SSO)\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const acc = active.ctx.account
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
|
||||
opts.io.out.write(acc.name !== ''
|
||||
? `${acc.email} (${acc.name})\n`
|
||||
: `${acc.email}\n`)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'inviter@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +35,7 @@ describe('runCreateMember', () => {
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -92,7 +91,7 @@ describe('runCreateMember', () => {
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runCreateMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
|
||||
@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runDeleteMember', () => {
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -46,7 +45,7 @@ describe('runDeleteMember', () => {
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runDeleteMember', () => {
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -51,7 +51,7 @@ export async function runDeleteMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
|
||||
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
format,
|
||||
data: await runDescribeApp(
|
||||
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -12,18 +12,17 @@ import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +49,7 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
|
||||
}
|
||||
@ -93,13 +92,13 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const before = cache.get(mock.url, 'app-1')
|
||||
expect(before).toBeDefined()
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1', refresh: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const after = cache.get(mock.url, 'app-1')
|
||||
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
|
||||
@ -113,7 +112,7 @@ describe('runDescribeApp', () => {
|
||||
await expect(runDescribeApp(
|
||||
{ appId: 'nope' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
|
||||
}
|
||||
|
||||
export type DescribeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io?: IOStreams
|
||||
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
format,
|
||||
data: result.data,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
|
||||
import { AppListOutput } from './handlers.js'
|
||||
import { runGetApp } from './run.js'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetApp', () => {
|
||||
@ -37,7 +36,7 @@ describe('runGetApp', () => {
|
||||
}
|
||||
|
||||
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
|
||||
const result = await runGetApp(opts, { active: baseActive, http: http() })
|
||||
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
|
||||
return stringifyOutput(table({
|
||||
format: opts.format ?? '',
|
||||
data: result.data,
|
||||
@ -135,11 +134,7 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
|
||||
const minimal: ActiveContext = {
|
||||
host: 'h',
|
||||
email: 'x@x.com',
|
||||
ctx: { account: { email: 'x@x.com', name: 'X' } },
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
|
||||
await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppsClient } from '../../../api/apps.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
@ -24,7 +24,7 @@ export type GetAppOptions = {
|
||||
}
|
||||
|
||||
export type GetAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
return runAllWorkspaces(apps, ws, opts, page, pageSize)
|
||||
}
|
||||
if (opts.appId !== undefined && opts.appId !== '') {
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, wsId)
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsName = workspaceNameForId(deps.bundle, wsId)
|
||||
const desc = await apps.describe(opts.appId, wsId, ['info'])
|
||||
return describeToEnvelope(desc, wsId, wsName)
|
||||
}
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
@ -111,13 +111,12 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
function workspaceNameForId(b: HostsBundle, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (b.workspace?.id === id)
|
||||
return b.workspace.name
|
||||
for (const w of b.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +37,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -55,7 +54,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -70,7 +69,7 @@ describe('runGetMember', () => {
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -79,20 +78,14 @@ describe('runGetMember', () => {
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when active context has no account id', async () => {
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const a: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: '', email: '', name: '' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
},
|
||||
}
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: a,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -103,16 +96,16 @@ describe('runGetMember', () => {
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
const noWs: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } },
|
||||
}
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
active: noWs,
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
@ -139,7 +132,7 @@ describe('MemberListOutput shape', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
|
||||
@ -16,7 +16,7 @@ export type GetMemberOptions = {
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -39,7 +39,7 @@ export async function runGetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -50,7 +50,7 @@ export async function runGetMember(
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.active.ctx.account?.id ?? ''
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand {
|
||||
const { flags } = this.parse(GetWorkspace, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
if (result.kind === 'empty')
|
||||
return raw(result.message)
|
||||
return table({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '../../../http/client.js'
|
||||
import { WorkspaceListOutput } from './handlers.js'
|
||||
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetWorkspace', () => {
|
||||
@ -36,8 +35,8 @@ describe('runGetWorkspace', () => {
|
||||
return createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
}
|
||||
|
||||
async function render(format = '', activeCtx = baseActive): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
|
||||
async function render(format = '', bundle = baseBundle): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { bundle, http: http() })
|
||||
if (result.kind === 'empty')
|
||||
return result.message
|
||||
return stringifyOutput(table({
|
||||
@ -76,8 +75,8 @@ describe('runGetWorkspace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to active context workspace.id when server current=false', async () => {
|
||||
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } }
|
||||
it('falls back to bundle workspace.id when server current=false', async () => {
|
||||
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
|
||||
const out = await render('', overridden)
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('ws-2'))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type GetWorkspaceDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
|
||||
)
|
||||
if (env.workspaces.length === 0)
|
||||
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
|
||||
const currentId = deps.active.ctx.workspace?.id ?? ''
|
||||
const currentId = deps.bundle.workspace?.id ?? ''
|
||||
return {
|
||||
kind: 'output',
|
||||
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(
|
||||
|
||||
@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { RunContext } from '../../run/app/_strategies/index.js'
|
||||
@ -30,7 +30,7 @@ export type ResumeAppOptions = {
|
||||
}
|
||||
|
||||
export type ResumeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -78,7 +78,7 @@ async function resolveInputs(
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -13,18 +13,17 @@ import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { resumeApp } from '../../resume/app/run.js'
|
||||
import { runApp } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +51,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: hi\n')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -63,7 +62,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
|
||||
})
|
||||
|
||||
@ -72,7 +71,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -82,7 +81,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -93,7 +92,7 @@ describe('runApp', () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', format: 'bogus' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
@ -102,7 +101,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'nope', message: 'hi' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -115,7 +114,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('echo: ')
|
||||
expect(io.outBuf()).toContain('hi')
|
||||
@ -127,7 +126,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -140,7 +139,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('do research')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -151,7 +150,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('go')
|
||||
expect(io.errBuf()).toContain('thought:')
|
||||
@ -162,7 +161,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
|
||||
expect(parsed.mode).toBe('workflow')
|
||||
@ -175,7 +174,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
})
|
||||
|
||||
@ -187,7 +186,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -199,7 +198,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/must be a JSON object/)
|
||||
})
|
||||
|
||||
@ -208,7 +207,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -220,7 +219,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, '{}')
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsJson: '{}', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
|
||||
@ -232,7 +231,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -261,7 +260,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -285,7 +284,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -296,7 +295,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -307,7 +306,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
// stream mode for workflow: node_started → "→ <title>" on stderr
|
||||
expect(io.errBuf()).toContain('After Resume')
|
||||
@ -318,7 +317,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(0)
|
||||
@ -339,7 +338,7 @@ describe('runApp', () => {
|
||||
await writeFile(filePath, 'fake pdf content')
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: [`doc=@${filePath}`] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(1)
|
||||
@ -356,7 +355,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -32,7 +32,7 @@ export type RunAppOptions = {
|
||||
}
|
||||
|
||||
export type RunAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -80,7 +80,7 @@ async function resolveInputs(
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, wsId, [FieldInfo])
|
||||
|
||||
@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runSetMember', () => {
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -47,7 +46,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -63,7 +62,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runSetMember', () => {
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type SetMemberOptions = {
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runSetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
|
||||
@ -26,8 +26,6 @@ import HelpExternal from './help/external/index.js'
|
||||
import ResumeApp from './resume/app/index.js'
|
||||
import RunApp from './run/app/index.js'
|
||||
import SetMember from './set/member/index.js'
|
||||
import UseAccount from './use/account/index.js'
|
||||
import UseHost from './use/host/index.js'
|
||||
import UseWorkspace from './use/workspace/index.js'
|
||||
import Version from './version/index.js'
|
||||
|
||||
@ -106,8 +104,6 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
account: { command: UseAccount, subcommands: {} },
|
||||
host: { command: UseHost, subcommands: {} },
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
export default class UseAccount extends DifyCommand {
|
||||
static override description = 'Switch the active account on the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use account',
|
||||
'<%= config.bin %> use account --email bob@corp.com',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
email: Flags.string({ description: 'account email to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseAccount, argv)
|
||||
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseAccount', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
it('errors when the account has no stored token', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
|
||||
.rejects
|
||||
.toThrow(/log in|no credential/i)
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when email omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
|
||||
})
|
||||
})
|
||||
@ -1,76 +0,0 @@
|
||||
import type { HostEntry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
|
||||
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
|
||||
|
||||
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
if (reg.current_host === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
const host = reg.current_host
|
||||
const entry = reg.hosts[host]
|
||||
if (entry === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
|
||||
const emails = Object.keys(entry.accounts)
|
||||
const target = opts.email ?? await pickAccount(opts, entry, host)
|
||||
if (!emails.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
hint: `run 'difyctl auth login --host ${host}'`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setAccount(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
|
||||
const emails = Object.keys(entry.accounts)
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
|
||||
email,
|
||||
name: ctx.account.name,
|
||||
sso: ctx.external_subject !== undefined,
|
||||
active: entry.current_account === email,
|
||||
}))
|
||||
const picked = await selectFromList<AccountChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: `Select an account on ${host}`,
|
||||
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
|
||||
})
|
||||
return picked.email
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
export default class UseHost extends DifyCommand {
|
||||
static override description = 'Switch the active Dify host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use host',
|
||||
'<%= config.bin %> use host --domain cloud.dify.ai',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
domain: Flags.string({ description: 'domain to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseHost, argv)
|
||||
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
describe('runUseHost', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_host when host is valid', async () => {
|
||||
await runUseHost({ io: bufferStreams(), host: 'h2' })
|
||||
expect(Registry.load().current_host).toBe('h2')
|
||||
})
|
||||
|
||||
it('errors when host is unknown, listing valid hosts', async () => {
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when host omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
|
||||
})
|
||||
|
||||
it('errors when no hosts exist', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
|
||||
})
|
||||
})
|
||||
@ -1,54 +0,0 @@
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseHostOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly host: string | undefined
|
||||
}
|
||||
|
||||
type HostChoice = { host: string, accounts: number, active: boolean }
|
||||
|
||||
export async function runUseHost(opts: UseHostOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
const hosts = Object.keys(reg.hosts)
|
||||
if (hosts.length === 0)
|
||||
throw notLoggedInError()
|
||||
|
||||
const target = opts.host ?? await pickHost(opts, reg, hosts)
|
||||
if (!hosts.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setHost(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: HostChoice[] = hosts.map(h => ({
|
||||
host: h,
|
||||
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
|
||||
active: reg.current_host === h,
|
||||
}))
|
||||
const picked = await selectFromList<HostChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: 'Select a host',
|
||||
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
|
||||
})
|
||||
return picked.host
|
||||
}
|
||||
@ -22,8 +22,7 @@ export default class UseWorkspace extends DifyCommand {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
|
||||
@ -3,36 +3,28 @@ import type {
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
return reg
|
||||
}
|
||||
|
||||
function makeActive(reg: Registry): ActiveContext {
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
throw new Error('resolveActive returned undefined in test setup')
|
||||
return active
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
@ -76,16 +68,14 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -94,65 +84,40 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -162,8 +127,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -172,18 +136,16 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = Registry.load()
|
||||
const after = loadHosts()
|
||||
expect(after).toEqual(before)
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -193,8 +155,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -202,15 +163,14 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = Registry.load()
|
||||
const after = loadHosts()
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -232,8 +192,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
@ -12,8 +13,7 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -31,12 +31,12 @@ export type UseWorkspaceDeps = {
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<Registry> {
|
||||
): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
@ -60,13 +60,16 @@ export async function runUseWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
}
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
saveHosts(next)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return deps.reg
|
||||
return next
|
||||
}
|
||||
|
||||
@ -1,95 +0,0 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectFromList } from './select'
|
||||
import { bufferStreams } from './streams'
|
||||
|
||||
type Row = { id: string, label: string }
|
||||
const rows: Row[] = [
|
||||
{ id: '1', label: 'alpha' },
|
||||
{ id: '2', label: 'beta' },
|
||||
{ id: '3', label: 'gamma' },
|
||||
]
|
||||
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
|
||||
type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown }
|
||||
|
||||
function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn {
|
||||
const stream = new PassThrough() as unknown as FakeTTYIn
|
||||
stream.isTTY = true
|
||||
stream.isRaw = false
|
||||
stream.setRawMode = (mode: boolean): unknown => {
|
||||
if (opts.failRawMode === true && mode)
|
||||
throw new Error('raw mode unavailable')
|
||||
stream.isRaw = mode
|
||||
return stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> {
|
||||
const io = bufferStreams()
|
||||
;(io as { in: NodeJS.ReadableStream }).in = input
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = true
|
||||
return io
|
||||
}
|
||||
|
||||
describe('selectFromList (non-TTY numbered fallback)', () => {
|
||||
it('returns the item matching the typed number', async () => {
|
||||
const io = bufferStreams('2\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label })
|
||||
expect(picked.id).toBe('2')
|
||||
expect(io.errBuf()).toContain('1) alpha')
|
||||
expect(io.errBuf()).toContain('Pick one')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range selection', async () => {
|
||||
const io = bufferStreams('9\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/invalid selection/i)
|
||||
})
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const io = bufferStreams('1\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label }))
|
||||
.rejects
|
||||
.toThrow(/nothing to select/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFromList (interactive TTY picker)', () => {
|
||||
it('moves with arrow keys and resolves on enter, restoring raw mode', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B[B')
|
||||
input.write('\r')
|
||||
const picked = await pick
|
||||
expect(picked.id).toBe('2')
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
|
||||
it('cancels on escape', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B')
|
||||
await expect(pick).rejects.toThrow(/cancelled/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects and restores the terminal when raw-mode setup fails', async () => {
|
||||
const input = ttyInput({ failRawMode: true })
|
||||
const io = ttyStreams(input)
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/raw mode unavailable/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
})
|
||||
@ -1,153 +0,0 @@
|
||||
import type { Key } from 'node:readline'
|
||||
import type { IOStreams } from './streams'
|
||||
import * as readline from 'node:readline'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from './color.js'
|
||||
|
||||
export type SelectOptions<T> = {
|
||||
readonly io: IOStreams
|
||||
readonly items: readonly T[]
|
||||
readonly header: string
|
||||
/** Single rich line shown per option. */
|
||||
readonly render: (item: T) => string
|
||||
/** Optional second line shown only for the focused option in the TTY picker. */
|
||||
readonly describe?: (item: T) => string
|
||||
}
|
||||
|
||||
const HIDE_CURSOR = '\x1B[?25l'
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
const CLEAR_DOWN = '\x1B[0J'
|
||||
const cursorUp = (n: number): string => `\x1B[${n}A`
|
||||
|
||||
export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
if (opts.items.length === 0)
|
||||
throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' })
|
||||
return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-key picker built on Node's readline keypress events — no third-party
|
||||
* prompt library, so it bundles cleanly into the compiled binary. Renders to
|
||||
* the err stream, redrawing in place on each keystroke and erasing itself on
|
||||
* exit so the caller's own output starts on a clean row.
|
||||
*/
|
||||
async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
const input = opts.io.in as NodeJS.ReadStream
|
||||
const out = opts.io.err
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const count = opts.items.length
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = 0
|
||||
let rendered = 0
|
||||
|
||||
const frame = (): readonly string[] => {
|
||||
const lines = [opts.header]
|
||||
opts.items.forEach((item, i) => {
|
||||
const focused = i === active
|
||||
const pointer = focused ? cs.cyan('❯') : ' '
|
||||
const label = focused ? cs.bold(opts.render(item)) : opts.render(item)
|
||||
lines.push(`${pointer} ${label}`)
|
||||
})
|
||||
const desc = opts.describe?.(opts.items[active] as T)
|
||||
if (desc !== undefined && desc !== '')
|
||||
lines.push(cs.dim(` ${desc}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
const render = (): void => {
|
||||
if (rendered > 0)
|
||||
out.write(cursorUp(rendered))
|
||||
const lines = frame()
|
||||
out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`)
|
||||
rendered = lines.length
|
||||
}
|
||||
|
||||
const wasRaw = input.isTTY ? input.isRaw : false
|
||||
const cleanup = (): void => {
|
||||
input.off('keypress', onKey)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(wasRaw)
|
||||
input.pause()
|
||||
if (rendered > 0)
|
||||
out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`)
|
||||
out.write(SHOW_CURSOR)
|
||||
}
|
||||
|
||||
function onKey(_str: string | undefined, key: Key): void {
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
return
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'up':
|
||||
case 'k':
|
||||
active = (active - 1 + count) % count
|
||||
render()
|
||||
break
|
||||
case 'down':
|
||||
case 'j':
|
||||
active = (active + 1) % count
|
||||
render()
|
||||
break
|
||||
case 'return':
|
||||
case 'enter': {
|
||||
const chosen = opts.items[active]
|
||||
cleanup()
|
||||
if (chosen === undefined)
|
||||
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }))
|
||||
else
|
||||
resolve(chosen)
|
||||
break
|
||||
}
|
||||
case 'escape':
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
readline.emitKeypressEvents(input)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(true)
|
||||
out.write(HIDE_CURSOR)
|
||||
input.on('keypress', onKey)
|
||||
input.resume()
|
||||
render()
|
||||
}
|
||||
catch (err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelled(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' })
|
||||
}
|
||||
|
||||
async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
opts.io.err.write(`${opts.header}\n`)
|
||||
opts.items.forEach((item, idx) => {
|
||||
opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`)
|
||||
})
|
||||
opts.io.err.write('Enter number: ')
|
||||
|
||||
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
|
||||
try {
|
||||
const line: string = await new Promise(resolve => rl.once('line', resolve))
|
||||
const n = Number(line.trim())
|
||||
const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined
|
||||
if (chosen === undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` })
|
||||
return chosen
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,30 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../test/fixtures/dify-mock/server.js'
|
||||
import { Registry } from '../auth/hosts.js'
|
||||
import { saveHosts } from '../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { arch } from '../sys/index.js'
|
||||
import { runVersionProbe } from './probe.js'
|
||||
|
||||
function active(overrides: Partial<ActiveContext> = {}): ActiveContext {
|
||||
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'test@dify.ai',
|
||||
ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } },
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
...overrides,
|
||||
}
|
||||
} as HostsBundle
|
||||
}
|
||||
|
||||
describe('runVersionProbe', () => {
|
||||
it('returns skipped server + unknown compat when skipServer=true', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -38,7 +38,7 @@ describe('runVersionProbe', () => {
|
||||
let observed: string | undefined
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }),
|
||||
probe: async (endpoint) => {
|
||||
observed = endpoint
|
||||
return { version: '1.6.4', edition: 'CLOUD' }
|
||||
@ -49,10 +49,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns no-host + unknown compat when active context is missing', async () => {
|
||||
it('returns no-host + unknown compat when bundle is missing', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -61,10 +61,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('no host')
|
||||
})
|
||||
|
||||
it('returns no-host when active context has empty host', async () => {
|
||||
it('returns no-host when bundle has empty current_host', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active({ host: '' }),
|
||||
loadBundle: async () => bundle({ current_host: '' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -72,10 +72,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('distinguishes loadActive disk failure from no-host configured in the detail', async () => {
|
||||
it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => {
|
||||
const errReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => { throw new Error('disk-explode') },
|
||||
loadBundle: async () => { throw new Error('disk-explode') },
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(errReport.server.reachable).toBe(false)
|
||||
@ -84,7 +84,7 @@ describe('runVersionProbe', () => {
|
||||
|
||||
const noHostReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(noHostReport.compat.detail).toContain('no host')
|
||||
@ -94,7 +94,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns compatible report when server is reachable and in range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -108,7 +108,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -119,7 +119,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -130,7 +130,7 @@ describe('runVersionProbe', () => {
|
||||
it('treats probe rejection as unreachable + unknown compat', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => { throw new Error('timeout') },
|
||||
})
|
||||
|
||||
@ -141,10 +141,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('builds endpoint using active scheme when host has no scheme', async () => {
|
||||
it('builds endpoint using bundle scheme when host has no scheme', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }),
|
||||
loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -161,12 +161,12 @@ describe('runVersionProbe', () => {
|
||||
const prevConfig = process.env[ENV_CONFIG_DIR]
|
||||
try {
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } })
|
||||
reg.setHost(url.host)
|
||||
reg.setAccount('test@dify.ai')
|
||||
reg.setScheme(url.host, url.protocol.replace(':', ''))
|
||||
reg.save()
|
||||
saveHosts({
|
||||
current_host: url.host,
|
||||
scheme: url.protocol.replace(':', ''),
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
|
||||
const report = await runVersionProbe({ skipServer: false })
|
||||
@ -190,7 +190,7 @@ describe('runVersionProbe', () => {
|
||||
it('always includes client metadata in the report', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { CompatVerdict } from './compat.js'
|
||||
import type { Channel } from './info.js'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
|
||||
import { Registry } from '../auth/hosts.js'
|
||||
import { loadHosts } from '../auth/hosts.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { arch, platform } from '../sys/index.js'
|
||||
import { hostWithScheme } from '../util/host.js'
|
||||
@ -43,13 +43,11 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse>
|
||||
|
||||
export type RunVersionProbeOptions = {
|
||||
readonly skipServer: boolean
|
||||
readonly loadActive?: () => Promise<ActiveContext | undefined>
|
||||
readonly loadBundle?: () => Promise<HostsBundle | undefined>
|
||||
readonly probe?: MetaProbe
|
||||
}
|
||||
|
||||
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
|
||||
return Registry.load().resolveActive()
|
||||
}
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
@ -91,19 +89,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const loadActive = opts.loadActive ?? defaultLoadActive
|
||||
const loadBundle = opts.loadBundle ?? defaultLoadBundle
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
|
||||
let active: ActiveContext | undefined
|
||||
let bundle: HostsBundle | undefined
|
||||
let loadFailed = false
|
||||
try {
|
||||
active = await loadActive()
|
||||
bundle = await loadBundle()
|
||||
}
|
||||
catch {
|
||||
loadFailed = true
|
||||
}
|
||||
|
||||
if (active === undefined || active.host === '') {
|
||||
if (bundle === undefined || bundle.current_host === '') {
|
||||
const detail = loadFailed ? 'hosts file unreadable' : 'no host configured'
|
||||
return {
|
||||
client,
|
||||
@ -112,7 +110,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = hostWithScheme(active.host, active.scheme)
|
||||
const endpoint = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
|
||||
let serverInfo: ServerVersionResponse | undefined
|
||||
try {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
|
||||
export type WorkspaceResolveInputs = {
|
||||
readonly flag?: string
|
||||
readonly env?: string
|
||||
readonly active?: ActiveContext
|
||||
readonly bundle?: HostsBundle
|
||||
}
|
||||
|
||||
export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
return inputs.flag
|
||||
if (truthy(inputs.env))
|
||||
return inputs.env
|
||||
const ctx = inputs.active?.ctx
|
||||
if (ctx !== undefined) {
|
||||
if (truthy(ctx.workspace?.id))
|
||||
return ctx.workspace.id
|
||||
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
|
||||
&& truthy(ctx.available_workspaces[0]?.id)) {
|
||||
return ctx.available_workspaces[0].id
|
||||
const b = inputs.bundle
|
||||
if (b !== undefined) {
|
||||
if (truthy(b.workspace?.id))
|
||||
return b.workspace.id
|
||||
if (b.available_workspaces !== undefined && b.available_workspaces.length > 0
|
||||
&& truthy(b.available_workspaces[0]?.id)) {
|
||||
return b.available_workspaces[0].id
|
||||
}
|
||||
}
|
||||
throw new BaseError({
|
||||
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -1,7 +1,6 @@
|
||||
export type Scenario
|
||||
= | 'happy'
|
||||
| 'sso'
|
||||
| 'no-email'
|
||||
| 'denied'
|
||||
| 'expired'
|
||||
| 'auth-expired'
|
||||
|
||||
10
cli/test/fixtures/dify-mock/server.ts
vendored
10
cli/test/fixtures/dify-mock/server.ts
vendored
@ -362,16 +362,6 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
if (scenario === 'no-email') {
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
account: { id: ACCOUNT.id, email: '', name: '' },
|
||||
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
default_workspace_id: 'ws-1',
|
||||
token_id: 'tok-1',
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
|
||||
143
docs/integrations-folder-structure-follow-ups.md
Normal file
143
docs/integrations-folder-structure-follow-ups.md
Normal file
@ -0,0 +1,143 @@
|
||||
# Integrations Folder Structure Follow-ups
|
||||
|
||||
Context: the onboarding UI rewrite moved Integrations into a first-class `/integrations/...` route family, but the implementation still intentionally reuses several legacy Account Settings, Tools, and Plugins components. This document records the recommended cleanup order after this branch is merged into `main`.
|
||||
|
||||
## Current Structure
|
||||
|
||||
| Area | Current location | Status |
|
||||
| --- | --- | --- |
|
||||
| MainNav shell | `web/app/components/main-nav` | Healthy. Keep the shallow `components/` layout. |
|
||||
| Integrations route adapter | `web/app/(commonLayout)/integrations/[[...slug]]/page.tsx` | Canonical route entry. |
|
||||
| Integrations route contract | `web/app/components/tools/integration-routes.ts` | Works, but ownership belongs to Integrations long-term. |
|
||||
| Integrations shell and sidebar | `web/app/components/tools/integrations-page.tsx` | Works, but ownership belongs to Integrations long-term. |
|
||||
| Integrations section renderer | `web/app/components/tools/integration-section-renderer.tsx` | Reuse-first adapter for existing pages. |
|
||||
| Model Provider page | `web/app/components/header/account-setting/model-provider-page` | Still active and widely imported. Do not move first. |
|
||||
| Data Source page | `web/app/components/header/account-setting/data-source-page-new` | Still active and reused by Integrations. Do not move first. |
|
||||
| API Extension page | `web/app/components/header/account-setting/api-based-extension-page` | Still active and reused by Integrations. Do not move first. |
|
||||
| Plugin management primitives | `web/app/components/plugins/plugin-page` | Still active for `/plugins` and reused by Integrations. Do not delete. |
|
||||
|
||||
## Recommended Timing
|
||||
|
||||
Do not do a broad folder move in the current onboarding UI branch. This branch already carries UI, route, i18n, and permission behavior changes; adding large path churn would increase merge conflict risk with `main` and make review harder.
|
||||
|
||||
After this branch is merged into `main`, do the structure cleanup in small PRs.
|
||||
|
||||
## Step 1: Establish Integrations Ownership
|
||||
|
||||
When: first cleanup PR after the onboarding UI branch lands on `main`.
|
||||
|
||||
Goal: make the new feature ownership visible without moving legacy implementation internals.
|
||||
|
||||
Recommended target:
|
||||
|
||||
```text
|
||||
web/app/components/integrations/
|
||||
routes.ts
|
||||
integrations-page.tsx
|
||||
integration-section-renderer.tsx
|
||||
sidebar/
|
||||
sections/
|
||||
hooks/
|
||||
```
|
||||
|
||||
Move or wrap only Integrations-owned shell files first:
|
||||
|
||||
| Current file | Target |
|
||||
| --- | --- |
|
||||
| `web/app/components/tools/integration-routes.ts` | `web/app/components/integrations/routes.ts` |
|
||||
| `web/app/components/tools/integrations-page.tsx` | `web/app/components/integrations/integrations-page.tsx` |
|
||||
| `web/app/components/tools/integration-section-renderer.tsx` | `web/app/components/integrations/integration-section-renderer.tsx` |
|
||||
| `web/app/components/tools/integration-page-header.tsx` | `web/app/components/integrations/integration-page-header.tsx` |
|
||||
| `web/app/components/tools/integration-sidebar-nav-item.tsx` | `web/app/components/integrations/sidebar/nav-item.tsx` |
|
||||
| `web/app/components/tools/integration-sidebar-nav-item-styles.ts` | `web/app/components/integrations/sidebar/nav-item-styles.ts` |
|
||||
| `web/app/components/tools/permission-quick-panel.tsx` | `web/app/components/integrations/sidebar/permission-quick-panel.tsx` |
|
||||
| `web/app/components/tools/hooks/use-integration-*` | `web/app/components/integrations/hooks/*` |
|
||||
|
||||
Keep compatibility re-exports from the old `components/tools` paths during this PR if the import churn becomes large.
|
||||
|
||||
## Step 2: Add Section Adapters
|
||||
|
||||
When: same PR as Step 1 if the diff stays small, otherwise a second cleanup PR.
|
||||
|
||||
Goal: avoid importing legacy Account Settings components directly from the shared renderer.
|
||||
|
||||
Add thin adapters:
|
||||
|
||||
```text
|
||||
web/app/components/integrations/sections/model-provider-section.tsx
|
||||
web/app/components/integrations/sections/data-source-section.tsx
|
||||
web/app/components/integrations/sections/api-extension-section.tsx
|
||||
web/app/components/integrations/sections/tools-section.tsx
|
||||
web/app/components/integrations/sections/plugin-category-section.tsx
|
||||
```
|
||||
|
||||
These adapters should keep importing the existing implementation from its current location. For example, `model-provider-section.tsx` can wrap `header/account-setting/model-provider-page` and pass Integrations-specific props such as `stickyToolbar`, `fixedWarningAlignment`, and `hideSystemModelSelectorProviderSettingsFooter`.
|
||||
|
||||
Do not duplicate page logic in the adapters.
|
||||
|
||||
## Step 3: Remove Confirmed Dead Account Settings Pages
|
||||
|
||||
When: after Step 1 and Step 2 are merged and the app still passes route/modal smoke tests.
|
||||
|
||||
Likely removal candidates:
|
||||
|
||||
| Candidate | Reason |
|
||||
| --- | --- |
|
||||
| `web/app/components/header/account-setting/Integrations-page` | Superseded by the new Integrations shell. No production reference should remain. |
|
||||
| `web/app/components/header/account-setting/plugin-page` | Superseded by `web/app/components/plugins/plugin-page`. No production reference should remain. |
|
||||
|
||||
Before deleting, run a fresh reference check:
|
||||
|
||||
```bash
|
||||
rg "Integrations-page|header/account-setting/plugin-page" web/app web/context web/service
|
||||
```
|
||||
|
||||
Delete only if the remaining hits are tests for the files being removed.
|
||||
|
||||
## Step 4: Consider Deeper Page Moves Later
|
||||
|
||||
When: only after the Integrations shell ownership is stable and `main` has absorbed the onboarding rewrite.
|
||||
|
||||
Do not start by moving these directories:
|
||||
|
||||
| Directory | Why |
|
||||
| --- | --- |
|
||||
| `header/account-setting/model-provider-page` | Its types, hooks, model selector, model auth, and modals are imported by workflows, datasets, app debug, services, and global modal context. |
|
||||
| `header/account-setting/data-source-page-new` | Its types and credential flows are used by dataset creation, Notion selectors, and Integrations. |
|
||||
| `header/account-setting/api-based-extension-page` | It is still reused by feature settings and Integrations. |
|
||||
| `plugins/plugin-page` | `/plugins`, Integrations install controls, plugin category pages, plugin task status, and plugin detail flows still depend on it. |
|
||||
|
||||
If these are moved later, split the work by domain:
|
||||
|
||||
1. Extract shared types/hooks into stable shared modules.
|
||||
2. Move only one page family per PR.
|
||||
3. Keep temporary re-export files at old paths if external imports are still broad.
|
||||
4. Remove re-exports only after the branch has settled on `main`.
|
||||
|
||||
## Merge Conflict Strategy
|
||||
|
||||
Prefer additive changes first:
|
||||
|
||||
- New adapter files under `components/integrations`.
|
||||
- Small import updates in the route adapter and renderer.
|
||||
- Temporary re-exports from old paths when needed.
|
||||
|
||||
Avoid early broad changes:
|
||||
|
||||
- Large `git mv` batches.
|
||||
- Renaming model-provider/data-source imports across workflow and dataset code.
|
||||
- Deleting reused plugin primitives.
|
||||
- Combining structure cleanup with visual changes.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Run at least:
|
||||
|
||||
```bash
|
||||
pnpm test app/components/tools/__tests__/integrations-page.spec.tsx
|
||||
pnpm test app/components/tools/__tests__/integration-routes.spec.ts
|
||||
pnpm test app/components/main-nav/__tests__/index.spec.tsx
|
||||
pnpm eslint --cache --quiet app/components/tools app/components/main-nav
|
||||
```
|
||||
|
||||
For deeper moves, also run targeted tests for the moved page family, such as model-provider, data-source, API extension, plugin-page, and plugin detail panel tests.
|
||||
140
docs/integrations-route-contract.md
Normal file
140
docs/integrations-route-contract.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Integrations Route Contract
|
||||
|
||||
This document records the current canonical routes for the Integrations navigation, the legacy routes that redirect to them, and the remaining migration gaps. The first migration only moves existing pages onto the new routes; UI redesign work is out of scope.
|
||||
|
||||
## Current Status
|
||||
|
||||
Completed:
|
||||
|
||||
| Area | Status |
|
||||
| --- | --- |
|
||||
| Canonical `/integrations/...` route adapter | Implemented in `web/app/(commonLayout)/integrations/[[...slug]]/page.tsx`. |
|
||||
| Route contract utility | Implemented in `web/app/components/tools/integration-routes.ts`. |
|
||||
| Existing page reuse | Implemented through `IntegrationSectionRenderer`; no duplicated UI copy. |
|
||||
| Legacy `/tools?...` redirects | Implemented through `web/app/(commonLayout)/tools/page.tsx`. |
|
||||
| Legacy `/plugins` installed redirects | Implemented through `web/app/(commonLayout)/plugins/page.tsx`. |
|
||||
| Tools tab navigation under new URLs | Implemented; scoped tool tabs push canonical `/integrations/tools/...` URLs. |
|
||||
| Singular-only canonical URLs | Implemented; plural and misplaced aliases are intentionally unsupported. |
|
||||
|
||||
Not completed:
|
||||
|
||||
| Area | Remaining work |
|
||||
| --- | --- |
|
||||
| Integrations overview page | Not introduced; `/integrations` currently redirects to `/integrations/model-provider`. |
|
||||
| Tools overview page | Not introduced; `/integrations/tools` currently redirects to `/integrations/tools/built-in`. |
|
||||
| Plugin route migration | `/plugins` still owns the old plugin management and marketplace surface; no `/integrations/plugin` route will be introduced. Non-marketplace plugin URLs should redirect to `/integrations`. |
|
||||
| Marketplace route migration | `/marketplace/...` routes below are future recommendations only; they are not implemented here. |
|
||||
| New onboarding UI redesign | Not started in this route migration; current pages intentionally reuse existing UI. |
|
||||
| Marketplace plugin redirects | Not implemented; marketplace plugin URLs intentionally keep rendering the legacy plugin marketplace surface for now. |
|
||||
|
||||
## Navigation Labels
|
||||
|
||||
| Navigation item | Canonical label |
|
||||
| --- | --- |
|
||||
| Model Provider | Model Provider |
|
||||
| Built-in tools | Built-in |
|
||||
| Custom Tool | Swagger API as Tool |
|
||||
| Workflow | Workflow as Tool |
|
||||
| MCP | MCP |
|
||||
| Data Source | Data Source |
|
||||
| API Extension | API Extension |
|
||||
| Plugins | Plugins |
|
||||
| Marketplace | Marketplace |
|
||||
|
||||
## Canonical Integrations Routes
|
||||
|
||||
| Route | Destination |
|
||||
| --- | --- |
|
||||
| `/integrations` | Redirect to `/integrations/model-provider` unless an overview page is introduced. |
|
||||
| `/integrations/model-provider` | Existing model provider management page. |
|
||||
| `/integrations/tools` | Redirect to `/integrations/tools/built-in` unless a tools overview page is introduced. |
|
||||
| `/integrations/tools/built-in` | Existing built-in tools list. |
|
||||
| `/integrations/tool/api` | Existing custom API tool list, relabeled as Swagger API as Tool. |
|
||||
| `/integrations/tools/workflow` | Existing Workflow as Tool management page. |
|
||||
| `/integrations/tools/mcp` | Existing MCP tools management page. |
|
||||
| `/integrations/trigger` | Existing plugin trigger list filtered from plugin management. |
|
||||
| `/integrations/agent-strategy` | Existing agent strategy plugin list filtered from plugin management. |
|
||||
| `/integrations/extension` | Existing extension plugin list filtered from plugin management. |
|
||||
| `/integrations/data-source` | Existing data source page. |
|
||||
| `/integrations/tools/api-extension` | Existing API extension page under the Tools group. |
|
||||
|
||||
## Integration Plugin Category Routes
|
||||
|
||||
These navigation items use plugin categories from the existing plugin management surface:
|
||||
|
||||
| Navigation item | Plugin category | Route |
|
||||
| --- | --- | --- |
|
||||
| Trigger | `trigger` | `/integrations/trigger` |
|
||||
| Agent Strategy | `agent-strategy` | `/integrations/agent-strategy` |
|
||||
| Extension | `extension` | `/integrations/extension` |
|
||||
|
||||
The install and filter controls in the Integrations sidebar are disabled actions, not route destinations.
|
||||
|
||||
These routes reuse the installed plugin management list with an initial category filter. They are not marketplace category pages.
|
||||
|
||||
Do not treat every plugin category as an Integrations navigation item automatically. `trigger`, `agent-strategy`, and `extension` are currently exposed under Integrations because they are explicit navigation items. Other plugin categories have different product meanings:
|
||||
|
||||
| Plugin category | Integrations relationship |
|
||||
| --- | --- |
|
||||
| `tool` | Not equal to `/integrations/tools/...`; tool plugins can expose tool providers that appear in Tools, but the Tools page is provider-based. |
|
||||
| `model` | Not equal to `/integrations/model-provider`; model providers are managed through the model provider page. |
|
||||
| `datasource` | Not equal to the full Data Source page; data source integrations have their own existing page. |
|
||||
| `trigger` | Reused as `/integrations/trigger`, installed plugins filtered by category. |
|
||||
| `agent-strategy` | Reused as `/integrations/agent-strategy`, installed plugins filtered by category. |
|
||||
| `extension` | Reused as `/integrations/extension`, installed plugins filtered by category. |
|
||||
|
||||
## Legacy Tools Redirects
|
||||
|
||||
| Legacy route | New route |
|
||||
| --- | --- |
|
||||
| `/tools` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=provider` | `/integrations/model-provider` |
|
||||
| `/tools?section=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=builtin&category=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?category=builtin` | `/integrations/tools/built-in` |
|
||||
| `/tools?section=custom-tool` | `/integrations/tool/api` |
|
||||
| `/tools?section=custom-tool&category=api` | `/integrations/tool/api` |
|
||||
| `/tools?category=api` | `/integrations/tool/api` |
|
||||
| `/tools?section=workflow-tool` | `/integrations/tools/workflow` |
|
||||
| `/tools?section=workflow-tool&category=workflow` | `/integrations/tools/workflow` |
|
||||
| `/tools?category=workflow` | `/integrations/tools/workflow` |
|
||||
| `/tools?section=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?section=mcp&category=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?category=mcp` | `/integrations/tools/mcp` |
|
||||
| `/tools?section=data-source` | `/integrations/data-source` |
|
||||
| `/tools?section=api-based-extension` | `/integrations/tools/api-extension` |
|
||||
| `/tools?section=trigger` | `/integrations/trigger` |
|
||||
| `/tools?section=agent-strategy` | `/integrations/agent-strategy` |
|
||||
| `/tools?section=extension` | `/integrations/extension` |
|
||||
|
||||
Preserve non-routing query parameters such as `q`, `tags`, and `sort`, but drop legacy routing parameters such as `section` and `category` during redirects.
|
||||
|
||||
## Non-Canonical Integrations Routes
|
||||
|
||||
Do not add plural or misplaced alias redirects for new Integrations URLs. Only the singular canonical routes above should resolve. For example, `/integrations/model-providers`, `/integrations/data-sources`, `/integrations/api-extensions`, `/integrations/tools/trigger`, `/integrations/tools/agent-strategy`, and `/integrations/tools/extension` should not be treated as supported URLs unless they are later confirmed to have shipped externally.
|
||||
|
||||
## Legacy Plugin Redirects
|
||||
|
||||
Plugins have two different product meanings today: installed plugin management and marketplace discovery. Only the non-marketplace plugin URLs should redirect into Integrations. There is no `/integrations/plugin` route.
|
||||
|
||||
| Old Plugin URL | Recommended redirect | Reason |
|
||||
| --- | --- | --- |
|
||||
| `/plugins` | `/integrations` | Installed plugin management entry should move into the Integrations main entry. |
|
||||
| `/plugins?tab=plugins` | `/integrations` | Explicit installed plugins tab; non-marketplace semantics. |
|
||||
| `/plugins?tab=discover` | Do not redirect to Integrations | Marketplace discovery. |
|
||||
| `/plugins?tab=all` | Do not redirect to Integrations | Marketplace category: all. |
|
||||
| `/plugins?tab=tool` | Do not redirect to Integrations | Marketplace tool category, not installed tools management. |
|
||||
| `/plugins?tab=model` | Do not redirect to Integrations | Marketplace model category. |
|
||||
| `/plugins?tab=trigger` | Do not redirect to Integrations | Marketplace trigger category. |
|
||||
| `/plugins?tab=agent-strategy` | Do not redirect to Integrations | Marketplace agent strategy category. |
|
||||
| `/plugins?tab=extension` | Do not redirect to Integrations | Marketplace extension category. |
|
||||
| `/plugins?tab=datasource` | Do not redirect to Integrations | Marketplace datasource category. |
|
||||
| `/plugins?tab=bundle` | Do not redirect to Integrations | Marketplace bundle category. |
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Add the canonical route map and route tests.
|
||||
2. Mount the existing pages under the new Integrations routes without UI redesign.
|
||||
3. Update internal links to generate canonical URLs.
|
||||
4. Add legacy redirects for `/tools` and `/plugins`.
|
||||
5. Keep compatibility tests for each legacy route until old links can be removed.
|
||||
97
docs/main-nav-gating-follow-ups.md
Normal file
97
docs/main-nav-gating-follow-ups.md
Normal file
@ -0,0 +1,97 @@
|
||||
# Main Nav Gating Follow-ups
|
||||
|
||||
Context: the desktop MainNav rewrite moved several workspace, account, tools, and marketplace entry points out of the old header/account-setting layout. These notes track product-contract questions that should be resolved before treating the rewrite as behavior-complete.
|
||||
|
||||
Current status:
|
||||
|
||||
- Open: account-setting modal navigation API naming, Marketplace/Integrations install task status parity, account language/timezone access parity.
|
||||
- Partially resolved: Apps/Datasets quick-switch/create parity.
|
||||
|
||||
## 1. Account-setting modal naming and moved destinations
|
||||
|
||||
Status: Open.
|
||||
|
||||
Current branch behavior:
|
||||
|
||||
- `setShowAccountSettingModal(PROVIDER)` routes to `/integrations/model-provider`.
|
||||
- `setShowAccountSettingModal(DATA_SOURCE)` routes to `/integrations/data-source`.
|
||||
- `setShowAccountSettingModal(API_BASED_EXTENSION)` routes to `/integrations/tools/api-extension`.
|
||||
- Document Settings no longer directly renders the old Account Settings modal for Provider; it uses the Integrations destination helper.
|
||||
|
||||
Old behavior: these calls opened the account-setting modal and switched to the matching tab.
|
||||
|
||||
Question:
|
||||
|
||||
- Since Provider, Data Source, and API-based Extension are no longer inside Account Settings in the new design, should this API still be named `setShowAccountSettingModal` for those destinations?
|
||||
|
||||
Follow-up decision needed:
|
||||
|
||||
- Either keep the compatibility shim but document that these payloads are now route destinations, or introduce a clearer navigation API for integration destinations and update call sites intentionally.
|
||||
- Re-check call sites launched from workflows, datasets, and app configuration when new entry points are added. The known Document Settings provider entry has been migrated.
|
||||
|
||||
## 2. Plugin and marketplace status parity
|
||||
|
||||
Status: Open.
|
||||
|
||||
Old header behavior:
|
||||
|
||||
- `PluginsNav` showed plugin install progress and error state through the installing icon and red indicator.
|
||||
- Installing tasks showed the downloading icon.
|
||||
- Failed or erroring install tasks showed the red status indicator.
|
||||
|
||||
Current MainNav behavior:
|
||||
|
||||
- MainNav has a Marketplace link, but it does not surface plugin installing/error state.
|
||||
- Integrations has install entry points, but it does not surface the old `PluginTasks` install-task status entry near the Integrations install action.
|
||||
- Marketplace is the product discovery surface for uninstalled integrations; `PluginTasks` is only the transient install-task status inbox for running/succeeded/failed installs.
|
||||
|
||||
Follow-up decision needed:
|
||||
|
||||
- Decide whether MainNav Marketplace should preserve the old plugin task status indicator.
|
||||
- Decide whether Integrations should expose `PluginTasks` near the install action so users can inspect failed/running install tasks without returning to the old `/plugins` shell.
|
||||
- If yes, reuse the existing `usePluginTaskStatus` behavior instead of creating a parallel status source.
|
||||
|
||||
## 3. Account language and timezone access
|
||||
|
||||
Status: Open.
|
||||
|
||||
Current branch behavior:
|
||||
|
||||
- Desktop MainNav account menu includes Language and Timezone submenus.
|
||||
- The main app layout now uses MainNav across breakpoints.
|
||||
- The default account dropdown does not expose a direct Language/Timezone settings entry.
|
||||
- The default account dropdown still exists in non-MainNav account/header surfaces such as the account layout.
|
||||
- Language and Timezone still belong to Account Settings, not Integrations.
|
||||
- `UpdateSettingPopover` still links the timezone hint to `ACCOUNT_SETTING_TAB.LANGUAGE`.
|
||||
- The legacy `ReferenceSettingModal` auto-update timezone hint also still links to `ACCOUNT_SETTING_TAB.LANGUAGE`.
|
||||
|
||||
Decision:
|
||||
|
||||
- Preserve the old language-access contract across breakpoints.
|
||||
- The desktop MainNav path is acceptable; the remaining question is default account-dropdown parity wherever that path remains active, plus whether hidden Account Settings Language entry points should remain acceptable.
|
||||
|
||||
Follow-up decision needed:
|
||||
|
||||
- Add an equivalent language/timezone entry to the default account path, or otherwise ensure users in those surfaces can still reach language settings.
|
||||
- Decide whether the Update Setting timezone hint should keep opening the hidden Account Settings Language page, or whether Account Settings should surface Language in its visible menu.
|
||||
- Keep this as gate-contract parity, not a visual requirement to recreate the old Account Settings sidebar.
|
||||
|
||||
## 4. Apps and Datasets quick-switch/create parity
|
||||
|
||||
Status: Partially resolved.
|
||||
|
||||
Old header behavior:
|
||||
|
||||
- `AppNav` could show the current app, list more apps, load more results, and launch create-app flows from the header nav.
|
||||
- `DatasetNav` could show the current dataset, list more datasets, load more results, and launch dataset creation from the header nav.
|
||||
|
||||
Current MainNav behavior:
|
||||
|
||||
- Apps is no longer only a static navigation link: MainNav includes a Web Apps section with installed web app search, pin, delete, and navigation behavior.
|
||||
- Apps still does not preserve the old `AppNav` current-app switcher, load-more behavior, or create-app flows.
|
||||
- Datasets is still a navigation link and does not preserve the old `DatasetNav` current-dataset switcher, load-more behavior, or dataset creation entry.
|
||||
|
||||
Follow-up decision needed:
|
||||
|
||||
- Decide whether the new design intentionally removes these quick-switch/create affordances.
|
||||
- If not, add equivalent behavior in the MainNav flow without copying the old header UI directly.
|
||||
@ -7,9 +7,13 @@ When('I open the publish panel', async function (this: DifyWorld) {
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
|
||||
await this.getPage()
|
||||
.getByRole('button', { name: /Publish Update/ })
|
||||
.click()
|
||||
})
|
||||
|
||||
Then('the app should be marked as published', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
|
||||
import {
|
||||
createTestApp,
|
||||
enableAppSiteAndGetURL,
|
||||
publishWorkflowApp,
|
||||
syncRunnableWorkflowDraft,
|
||||
} from '../../../support/api'
|
||||
|
||||
When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName)
|
||||
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
|
||||
)
|
||||
}
|
||||
|
||||
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
|
||||
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
|
||||
@ -28,8 +36,11 @@ Given('a workflow app has been published and shared via API', async function (th
|
||||
})
|
||||
|
||||
When('I open the shared app URL', async function (this: DifyWorld) {
|
||||
if (!this.shareURL)
|
||||
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
|
||||
if (!this.shareURL) {
|
||||
throw new Error(
|
||||
'No share URL available. Run "a workflow app has been published and shared via API" first.',
|
||||
)
|
||||
}
|
||||
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
|
||||
})
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
const testRunButton = page.getByRole('button', { name: /Test Run/ })
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('DETAIL').click()
|
||||
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
|
||||
await page.getByText('DETAIL', { exact: true }).click()
|
||||
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
@ -108,14 +108,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/svg-attribute-error-reproduction.spec.tsx": {
|
||||
"no-console": {
|
||||
"count": 19
|
||||
@ -142,11 +134,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -641,22 +628,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/new-app-card.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/action-button/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -2282,11 +2253,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/edit-metadata-batch/input-combined.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@ -2363,14 +2329,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/banner/banner-item.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/explore/banner/indicator-button.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -2890,11 +2848,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/empty/index.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/plugin-page/filter-management/category-filter.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3198,49 +3151,16 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/headers-input.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/mcp-server-param-item.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/provider-card.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/sections/authentication-section.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/mcp/sections/configurations-section.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider-list.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/provider/empty.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/setting/build-in/config-credentials.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
@ -5215,6 +5135,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-plugins.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
@ -5469,15 +5394,6 @@
|
||||
"web/service/use-plugins.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
},
|
||||
"regexp/no-unused-capturing-group": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user