mirror of
https://github.com/langgenius/dify.git
synced 2026-05-29 05:07:55 +08:00
Compare commits
290 Commits
main
...
feat/snipp
| Author | SHA1 | Date | |
|---|---|---|---|
| c46a313d78 | |||
| e1fec86a2a | |||
| 6ec893cb0e | |||
| 8be34ee000 | |||
| 458f669883 | |||
| 94fd4e9c67 | |||
| f5da3ce499 | |||
| 08f2971f72 | |||
| 54ac42fbc4 | |||
| 1d1d571213 | |||
| 056caa8b2f | |||
| 1cf6cdb764 | |||
| ac9083fbf1 | |||
| fdfc9ab3d3 | |||
| 83cd1a8d7a | |||
| a3dfd670b0 | |||
| facace019b | |||
| fd9543868d | |||
| 89188256e1 | |||
| bba3a1bcee | |||
| 7c0be7f905 | |||
| 599e3475f2 | |||
| 718fe548e9 | |||
| 060ceaffd1 | |||
| 00908ca0fb | |||
| 2812d61e24 | |||
| be1d6520f9 | |||
| 7fb2e4751f | |||
| 5441992604 | |||
| 9d0597c22d | |||
| 5d489ab92d | |||
| 930da499d1 | |||
| f1527ef7c1 | |||
| 20f89b6e90 | |||
| 05e69b104a | |||
| f39b1b6731 | |||
| a7005efab3 | |||
| f605288429 | |||
| 2bb3b439e0 | |||
| 75daf8e61b | |||
| bf30b11d0d | |||
| 778e472173 | |||
| 2885ba8519 | |||
| e23c3d1491 | |||
| 888292564b | |||
| 124b786dfb | |||
| dd54ca0cab | |||
| 8a72e46ce8 | |||
| f00f8e020f | |||
| aa078a854c | |||
| 712aae4d98 | |||
| bacadc4d35 | |||
| b060e81824 | |||
| b45f83492e | |||
| d1e1a4a8ab | |||
| 4519847e81 | |||
| 3763efbc7c | |||
| 552f202ca8 | |||
| dc76f4082f | |||
| 6d01095586 | |||
| b914e48a41 | |||
| da482ec455 | |||
| 48c38ace54 | |||
| 2b1496c857 | |||
| c15e437ff7 | |||
| 0ac0eccce4 | |||
| 678327e994 | |||
| b0478f4df7 | |||
| 00319f0e43 | |||
| 55eb894d8e | |||
| c59a80a41f | |||
| 24b482893d | |||
| ad58895b25 | |||
| 25fc518c5d | |||
| d92722e7ab | |||
| 4041fd7e5c | |||
| 06ea73a19b | |||
| 7384a3c121 | |||
| c18c953a7c | |||
| ae2df0c35e | |||
| dacc7fc740 | |||
| 9af2c1252c | |||
| 35bfe26a3a | |||
| 8686362aeb | |||
| f5955489ec | |||
| aaa15770d5 | |||
| 08c01c4f3f | |||
| 0903c30060 | |||
| b420298398 | |||
| 2607eb8d32 | |||
| d8173b1cda | |||
| c56f1a8216 | |||
| 31e74371ef | |||
| e48f13f173 | |||
| c574363cf6 | |||
| 70fd4a5c88 | |||
| 42889d23e5 | |||
| 3a7f09a250 | |||
| d95d4335bf | |||
| 735e88f673 | |||
| c55105bff3 | |||
| 77afc805e1 | |||
| 9dd73b4d47 | |||
| f2b12bfef7 | |||
| dbeaf79d77 | |||
| 63dcb4dd6c | |||
| 9df3a7bcf9 | |||
| 89163edd16 | |||
| eaa55aab1e | |||
| 8d3a690c0a | |||
| 5263a65ed6 | |||
| 24d3e8edba | |||
| b371dd2cdf | |||
| 597ad8c425 | |||
| 33f9d96caa | |||
| 689571df22 | |||
| a3242f0634 | |||
| f5112928b3 | |||
| bcd87ddc58 | |||
| 7c8a87af05 | |||
| 8e2d507e5c | |||
| b6fbec066d | |||
| bd136cadce | |||
| 0a934e1143 | |||
| c44ba62da3 | |||
| 76c0aed05c | |||
| e7fc22c6b3 | |||
| b91727b804 | |||
| 534fd79377 | |||
| 3ea4742b29 | |||
| 364c0eb6e2 | |||
| 322b3ff641 | |||
| 38736c154b | |||
| 129f681c59 | |||
| d776fc0827 | |||
| 7af6074cb5 | |||
| 7aa700bf2b | |||
| 0d47750b15 | |||
| a9dc57eeef | |||
| 5bfebd371d | |||
| f1da2c76d1 | |||
| b5dc774093 | |||
| b7fe45d800 | |||
| 7f5bbe0ee3 | |||
| 40632589a2 | |||
| e6e063138e | |||
| 605af8d60e | |||
| 8747e3a2d3 | |||
| 1712a2732a | |||
| 46bc76bae3 | |||
| 8c6dda125f | |||
| f6047aafe8 | |||
| dce5715982 | |||
| ea910b8e7d | |||
| e51af66d95 | |||
| f93b287949 | |||
| 627fbd2e86 | |||
| e4c056a57a | |||
| 23291398ec | |||
| 79fc352a5a | |||
| 8b6b3cddea | |||
| d1ca468c1e | |||
| ce28ad771c | |||
| ba951b01de | |||
| 670ab16ea1 | |||
| 4680535ecd | |||
| f96e63460e | |||
| 2df79c0404 | |||
| acef9630d5 | |||
| 12c3b2e0cd | |||
| 577707ae50 | |||
| 03325e9750 | |||
| a7ef8f9c12 | |||
| 40284d9f95 | |||
| 5efe8b8bd7 | |||
| 8dc6d736ee | |||
| 5316372772 | |||
| 4d1499ef75 | |||
| 0438285277 | |||
| 4879ea5cd5 | |||
| 2a1761ac06 | |||
| c29245c1cb | |||
| 5069694bba | |||
| d1a80a85c0 | |||
| 5c93d74dec | |||
| e52dbd49be | |||
| ccc8a5f278 | |||
| cfb5b9dfea | |||
| 73d95245f8 | |||
| fb91984fcb | |||
| 29cb1fa12e | |||
| 78240ed199 | |||
| 8f8707fd77 | |||
| ed3db06154 | |||
| 7c05a68876 | |||
| 6cfc0dd8e1 | |||
| 81baeae5c4 | |||
| a3010bdc0b | |||
| 8133e550ed | |||
| 2bb0eab636 | |||
| 5311b5d00d | |||
| 9b02ccdd12 | |||
| 231783eebe | |||
| 756606f478 | |||
| 6651c1c5da | |||
| 61e257b2a8 | |||
| 3ac4caf735 | |||
| 268ae1751d | |||
| 015cbf850b | |||
| 873e13c2fb | |||
| 688bf7e7a1 | |||
| a6ffff3b39 | |||
| 023fc55bd5 | |||
| 351b909a53 | |||
| 6bec4f65c9 | |||
| 74f87ce152 | |||
| 92c472ccc7 | |||
| b92b8becd1 | |||
| 23d0d6a65d | |||
| 1660067d6e | |||
| 0642475b85 | |||
| 8cb634c9bc | |||
| 768b41c3cf | |||
| ca88516d54 | |||
| 871a2a149f | |||
| 60e381eff0 | |||
| 768b3eb6f9 | |||
| 2f88da4a6d | |||
| a8cdf6964c | |||
| 985c3db4fd | |||
| 9636472db7 | |||
| 0ad268aa7d | |||
| a4ea33167d | |||
| 0f13aabea8 | |||
| 1e76ef5ccb | |||
| e6e3229d17 | |||
| dccf8e723a | |||
| c41ba7d627 | |||
| a6e9316de3 | |||
| 559d326cbd | |||
| abedf2506f | |||
| d01428b5bc | |||
| 0de1f17e5c | |||
| 17d07a5a43 | |||
| 3bdbea99a3 | |||
| b7683aedb1 | |||
| 515036e758 | |||
| 22b382527f | |||
| 2cfe4b5b86 | |||
| 6876c8041c | |||
| 7de45584ce | |||
| 5572d7c7e8 | |||
| db0a2fe52e | |||
| f0ae8d6167 | |||
| 2514e181ba | |||
| be2e6e9a14 | |||
| 875e2eac1b | |||
| c3c73ceb1f | |||
| 6318bf0a2a | |||
| 5e1f252046 | |||
| df3b960505 | |||
| 26bc108bf1 | |||
| a5cff32743 | |||
| d418dd8eec | |||
| 61702fe346 | |||
| 43f0c780c3 | |||
| 30ebf2bfa9 | |||
| 7e3027b5f7 | |||
| b3acf83090 | |||
| 36c3d6e48a | |||
| f782ac6b3c | |||
| feef2dd1fa | |||
| a716d8789d | |||
| 6816f89189 | |||
| bfcac64a9d | |||
| 664eb601a2 | |||
| 8e5cc4e0aa | |||
| 9f28575903 | |||
| 4b9a26a5e6 | |||
| 7b85adf1cc | |||
| c964708ebe | |||
| 883eb498c0 | |||
| 4d3738d225 | |||
| dd0dee739d | |||
| 4d19914fcb | |||
| 887c7710e9 | |||
| 7a722773c7 | |||
| a763aff58b | |||
| c1011f4e5c | |||
| f7afa103a5 |
@ -4,12 +4,6 @@ CLI command modules extracted from `commands.py`.
|
||||
|
||||
from .account import create_tenant, reset_email, reset_password
|
||||
from .data_migrate import data_migrate, legacy_model_types
|
||||
from .data_migration import (
|
||||
export_migration_data,
|
||||
export_migration_data_template,
|
||||
import_migration_data,
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .plugin import (
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
@ -32,12 +26,7 @@ from .retention import (
|
||||
restore_workflow_runs,
|
||||
)
|
||||
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
|
||||
from .system import (
|
||||
convert_to_agent_apps,
|
||||
fix_app_site_missing,
|
||||
reset_encrypt_key_pair,
|
||||
upgrade_db,
|
||||
)
|
||||
from .system import convert_to_agent_apps, fix_app_site_missing, reset_encrypt_key_pair, upgrade_db
|
||||
from .vector import (
|
||||
add_qdrant_index,
|
||||
migrate_annotation_vector_database,
|
||||
@ -59,13 +48,10 @@ __all__ = [
|
||||
"data_migrate",
|
||||
"delete_archived_workflow_runs",
|
||||
"export_app_messages",
|
||||
"export_migration_data",
|
||||
"export_migration_data_template",
|
||||
"extract_plugins",
|
||||
"extract_unique_plugins",
|
||||
"file_usage",
|
||||
"fix_app_site_missing",
|
||||
"import_migration_data",
|
||||
"install_plugins",
|
||||
"install_rag_pipeline_plugins",
|
||||
"legacy_model_types",
|
||||
@ -73,7 +59,6 @@ __all__ = [
|
||||
"migrate_data_for_plugin",
|
||||
"migrate_knowledge_vector_database",
|
||||
"migrate_oss",
|
||||
"migration_data_wizard",
|
||||
"old_metadata_migration",
|
||||
"remove_orphaned_files_on_storage",
|
||||
"reset_email",
|
||||
|
||||
@ -1,754 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
import click
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
from models.model import App
|
||||
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
|
||||
from services.data_migration.entities import (
|
||||
DependencyKind,
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
ReportContext,
|
||||
ResourceReportItem,
|
||||
)
|
||||
from services.data_migration.export_service import ExportConfigParser, MigrationExportService
|
||||
from services.data_migration.import_service import ImportRequest, MigrationImportService
|
||||
from services.data_migration.package_service import MigrationPackageService
|
||||
from services.data_migration.report_service import MigrationReportService
|
||||
|
||||
ID_STRATEGY_CHOICES = ["preserve-id", "generate-new-id"]
|
||||
CONFLICT_STRATEGY_CHOICES = ["fail", "skip", "update"]
|
||||
SUPPORTED_WIZARD_APP_MODES = ["workflow", "advanced-chat"]
|
||||
WizardToolMap = dict[str, dict[str, str | None]]
|
||||
WizardToolSelection = dict[str, list[str]]
|
||||
|
||||
|
||||
def _scripted_export_template() -> dict[str, Any]:
|
||||
return {
|
||||
"source_tenant": {
|
||||
"mode": "single",
|
||||
"id": "",
|
||||
"name": "admin's Workspace",
|
||||
},
|
||||
"apps": {
|
||||
"modes": ["workflow", "advanced-chat"],
|
||||
"ids": [],
|
||||
"all": True,
|
||||
},
|
||||
"include_referenced_tools": True,
|
||||
"additional_tools": {
|
||||
"api_tools": [],
|
||||
"workflow_tools": [],
|
||||
"mcp_tools": [],
|
||||
},
|
||||
"include_secrets": False,
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": False,
|
||||
"id_strategy": "preserve-id",
|
||||
"conflict_strategy": "fail",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@click.command("app-migration-template", help="Print or write a scripted export config JSON template.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"output_file",
|
||||
required=False,
|
||||
type=click.Path(dir_okay=False),
|
||||
help="Path to write the export config JSON template. Prints to stdout when omitted.",
|
||||
)
|
||||
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
|
||||
def export_migration_data_template(output_file: str | None, overwrite: bool) -> None:
|
||||
template_json = json.dumps(_scripted_export_template(), indent=2, ensure_ascii=False) + "\n"
|
||||
if output_file is None:
|
||||
click.echo(template_json, nl=False)
|
||||
return
|
||||
path = Path(output_file)
|
||||
if path.exists() and not overwrite:
|
||||
raise click.ClickException(f"Output file already exists: {output_file}")
|
||||
path.write_text(template_json)
|
||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
||||
|
||||
|
||||
@click.command("export-app-migration", help="Export workflow migration data to a versioned JSON package.")
|
||||
@click.option(
|
||||
"--input",
|
||||
"input_file",
|
||||
required=False,
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Path to export config JSON.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"output_file",
|
||||
required=False,
|
||||
type=click.Path(dir_okay=False),
|
||||
help="Path to migration package JSON.",
|
||||
)
|
||||
@click.option("--overwrite", is_flag=True, default=False, help="Overwrite output if it already exists.")
|
||||
def export_migration_data(input_file: str | None, output_file: str | None, overwrite: bool) -> None:
|
||||
try:
|
||||
_require_options(("--input", input_file), ("--output", output_file))
|
||||
assert input_file is not None
|
||||
assert output_file is not None
|
||||
raw_config = _load_json_object(input_file, "Export config")
|
||||
selection = ExportConfigParser().parse(raw_config)
|
||||
result = MigrationExportService().export(selection)
|
||||
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
|
||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
||||
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
|
||||
except MigrationDataError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
@click.command("import-app-migration", help="Import a versioned migration data package.")
|
||||
@click.option(
|
||||
"--input",
|
||||
"input_file",
|
||||
required=False,
|
||||
type=click.Path(exists=True, dir_okay=False),
|
||||
help="Path to migration package JSON.",
|
||||
)
|
||||
@click.option("--target-tenant", default=None, help="Target tenant/workspace name. Overrides package metadata.")
|
||||
@click.option("--operator-email", default=None, help="Operator account email in the target tenant.")
|
||||
@click.option(
|
||||
"--id-strategy",
|
||||
default=None,
|
||||
type=click.Choice(ID_STRATEGY_CHOICES),
|
||||
help="Override package ID strategy.",
|
||||
)
|
||||
@click.option(
|
||||
"--conflict-strategy",
|
||||
default=None,
|
||||
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
|
||||
help="Override package conflict strategy.",
|
||||
)
|
||||
@click.option(
|
||||
"--create-app-api-token-on-import/--no-create-app-api-token-on-import",
|
||||
default=None,
|
||||
help="Override package app API token creation behavior.",
|
||||
)
|
||||
def import_migration_data(
|
||||
input_file: str | None,
|
||||
target_tenant: str | None,
|
||||
operator_email: str | None,
|
||||
id_strategy: str | None,
|
||||
conflict_strategy: str | None,
|
||||
create_app_api_token_on_import: bool | None,
|
||||
) -> None:
|
||||
try:
|
||||
_require_options(("--input", input_file))
|
||||
assert input_file is not None
|
||||
package = MigrationPackageService().load_package(input_file)
|
||||
result = MigrationImportService().import_package(
|
||||
ImportRequest(
|
||||
package=package,
|
||||
cli_target_tenant=target_tenant,
|
||||
operator_email=operator_email,
|
||||
options_override=_build_options_override(
|
||||
package.metadata.import_options,
|
||||
id_strategy=id_strategy,
|
||||
conflict_strategy=conflict_strategy,
|
||||
create_app_api_token_on_import=create_app_api_token_on_import,
|
||||
),
|
||||
)
|
||||
)
|
||||
_render_report(result.report_items, context=result.report_context)
|
||||
except MigrationDataError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
def parse_index_selection(raw: str, values: list[str]) -> list[str]:
|
||||
normalized = raw.strip().lower()
|
||||
if normalized == "all":
|
||||
return values
|
||||
|
||||
selected: list[str] = []
|
||||
for part in raw.split(","):
|
||||
stripped = part.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
index = int(stripped)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(f"Selection must be 'all' or comma-separated numbers: {raw}") from exc
|
||||
if index < 1 or index > len(values):
|
||||
raise click.ClickException(f"Selection index out of range: {index}")
|
||||
selected.append(values[index - 1])
|
||||
return list(dict.fromkeys(selected))
|
||||
|
||||
|
||||
def _print_wizard_step(title: str) -> None:
|
||||
click.echo("")
|
||||
click.echo(f"==== {title} ====")
|
||||
|
||||
|
||||
def _print_wizard_substep(title: str) -> None:
|
||||
click.echo("")
|
||||
click.echo(f"-- {title} --")
|
||||
|
||||
|
||||
@click.command("app-migration-wizard", help="Interactively export workflow migration data.")
|
||||
def migration_data_wizard() -> None:
|
||||
try:
|
||||
tenant = _prompt_source_tenant()
|
||||
apps = _eligible_apps_for_tenant(tenant.id)
|
||||
app_ids = _prompt_app_ids(apps)
|
||||
_print_wizard_step("Referenced Tools")
|
||||
include_referenced_tools = click.confirm(
|
||||
"Automatically export tools referenced by selected apps? [y/n, default: y]",
|
||||
default=True,
|
||||
show_default=False,
|
||||
)
|
||||
auto_tools = _discover_auto_tools([app for app in apps if app.id in set(app_ids)], include_referenced_tools)
|
||||
auto_tools = _resolve_auto_tool_names(tenant.id, auto_tools)
|
||||
_print_auto_tools(auto_tools)
|
||||
additional_tools = _prompt_additional_tools(tenant.id, auto_tools)
|
||||
include_secrets, create_tokens, id_strategy, conflict_strategy = _prompt_import_options()
|
||||
_print_wizard_step("Output")
|
||||
output_file, overwrite = _prompt_output_file()
|
||||
|
||||
selection = ExportConfigParser().parse(
|
||||
{
|
||||
"source_tenant": {"mode": "single", "id": tenant.id, "name": tenant.name},
|
||||
"apps": {"ids": app_ids, "all": False},
|
||||
"include_referenced_tools": include_referenced_tools,
|
||||
"additional_tools": additional_tools,
|
||||
"include_secrets": include_secrets,
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": create_tokens,
|
||||
"id_strategy": id_strategy,
|
||||
"conflict_strategy": conflict_strategy,
|
||||
},
|
||||
}
|
||||
)
|
||||
_confirm_wizard_summary(
|
||||
tenant_name=tenant.name,
|
||||
app_names=[app.name for app in apps if app.id in set(app_ids)],
|
||||
auto_tools=auto_tools,
|
||||
additional_tools=additional_tools,
|
||||
manual_labels=_selected_tool_labels_for_tenant(tenant.id, additional_tools),
|
||||
include_referenced_tools=include_referenced_tools,
|
||||
include_secrets=include_secrets,
|
||||
create_tokens=create_tokens,
|
||||
id_strategy=id_strategy,
|
||||
conflict_strategy=conflict_strategy,
|
||||
output_file=output_file,
|
||||
)
|
||||
result = MigrationExportService().export(selection)
|
||||
MigrationPackageService().save_package(result.package, output_file, overwrite=overwrite)
|
||||
click.echo(click.style(f"Output written to {output_file}", fg="green"))
|
||||
_print_wizard_step("Report")
|
||||
_render_report(result.report_items, context=_with_output_path(result.report_context, output_file))
|
||||
except MigrationDataError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
|
||||
def _load_json_object(path: str, label: str) -> dict[str, Any]:
|
||||
try:
|
||||
with Path(path).open(encoding="utf-8") as file:
|
||||
raw = json.load(file)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise MigrationDataError(f"{label} JSON is invalid: {exc.msg}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise MigrationDataError(f"{label} JSON must be an object.")
|
||||
return raw
|
||||
|
||||
|
||||
def _require_options(*options: tuple[str, object | None]) -> None:
|
||||
missing_options = [name for name, value in options if value is None]
|
||||
if missing_options:
|
||||
raise click.UsageError(f"Missing option(s): {', '.join(missing_options)}.")
|
||||
|
||||
|
||||
def _build_options_override(
|
||||
package_options: ImportOptions,
|
||||
*,
|
||||
id_strategy: str | None,
|
||||
conflict_strategy: str | None,
|
||||
create_app_api_token_on_import: bool | None,
|
||||
) -> ImportOptions | None:
|
||||
if id_strategy is None and conflict_strategy is None and create_app_api_token_on_import is None:
|
||||
return None
|
||||
return ImportOptions.from_mapping(
|
||||
{
|
||||
"id_strategy": id_strategy or package_options.id_strategy,
|
||||
"conflict_strategy": conflict_strategy or package_options.conflict_strategy,
|
||||
"create_app_api_token_on_import": (
|
||||
create_app_api_token_on_import
|
||||
if create_app_api_token_on_import is not None
|
||||
else package_options.create_app_api_token_on_import
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _prompt_source_tenant() -> Tenant:
|
||||
tenants = list(db.session.scalars(sa.select(Tenant).order_by(Tenant.name.asc())).all())
|
||||
if not tenants:
|
||||
raise MigrationDataError("No tenants found.")
|
||||
|
||||
_print_wizard_step("Source Tenant")
|
||||
click.echo("Source tenants:")
|
||||
for index, tenant in enumerate(tenants, 1):
|
||||
click.echo(f"{index}. {tenant.name} ({tenant.id})")
|
||||
|
||||
tenant_index = click.prompt("Select one source tenant by number", type=int, default=1, show_default=True)
|
||||
if tenant_index < 1 or tenant_index > len(tenants):
|
||||
raise click.ClickException(f"Selection index out of range: {tenant_index}")
|
||||
return tenants[tenant_index - 1]
|
||||
|
||||
|
||||
def _eligible_apps_for_tenant(tenant_id: str) -> list[App]:
|
||||
return list(
|
||||
db.session.scalars(
|
||||
sa.select(App)
|
||||
.where(App.tenant_id == tenant_id, App.mode.in_(SUPPORTED_WIZARD_APP_MODES))
|
||||
.order_by(App.name.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _prompt_app_ids(apps: list[App]) -> list[str]:
|
||||
if not apps:
|
||||
raise MigrationDataError("No workflow or advanced-chat apps found for the selected tenant.")
|
||||
|
||||
_print_wizard_step("App Selection")
|
||||
click.echo("Currently supported app types: workflow and chatflow.")
|
||||
click.echo("Workflow/chatflow apps:")
|
||||
for index, app in enumerate(apps, 1):
|
||||
mode = app.mode.value if hasattr(app.mode, "value") else app.mode
|
||||
click.echo(f"{index}. {app.name} [{mode}] ({app.id})")
|
||||
app_ids = parse_index_selection(
|
||||
click.prompt("Select apps by number, comma-separated numbers, or all", default="all"),
|
||||
[app.id for app in apps],
|
||||
)
|
||||
selected_apps = [app for app in apps if app.id in set(app_ids)]
|
||||
click.echo("Selected apps:")
|
||||
for app in selected_apps:
|
||||
click.echo(f"- {app.name} ({app.id})")
|
||||
return app_ids
|
||||
|
||||
|
||||
def _prompt_import_options() -> tuple[bool, bool, str, str]:
|
||||
_print_wizard_step("Import Options")
|
||||
_print_wizard_substep("Secrets")
|
||||
click.echo("Secrets include workflow/app DSL secret values, custom API tool credentials,")
|
||||
click.echo("and full MCP provider connection data such as server URL, headers, authentication, and tool list.")
|
||||
click.echo("If you choose no, credentials are omitted or masked,")
|
||||
click.echo("and MCP providers are exported as dependency metadata only.")
|
||||
click.echo("Treat the output JSON as sensitive if you choose yes.")
|
||||
include_secrets = click.confirm(
|
||||
"Include secrets in output JSON? [y/n, default: n]",
|
||||
default=False,
|
||||
show_default=False,
|
||||
)
|
||||
_print_wizard_substep("App API Tokens")
|
||||
click.echo("When enabled, import will create an app API token if the imported app has none,")
|
||||
click.echo("or reuse an existing app API token if one already exists.")
|
||||
create_tokens = click.confirm(
|
||||
"Create or reuse app API tokens during import? [y/n, default: n]",
|
||||
default=False,
|
||||
show_default=False,
|
||||
)
|
||||
_print_wizard_substep("ID Strategy")
|
||||
click.echo("ID strategy controls whether imported app and tool IDs preserve source IDs")
|
||||
click.echo("or use target-generated IDs.")
|
||||
click.echo("preserve-id: keep source IDs where the target service supports it.")
|
||||
click.echo("generate-new-id: let the target environment generate new IDs and rewrite references via mapping.")
|
||||
id_strategy = click.prompt(
|
||||
"Import ID strategy. Enter one of: preserve-id, generate-new-id",
|
||||
type=click.Choice(ID_STRATEGY_CHOICES),
|
||||
default="preserve-id",
|
||||
show_default=True,
|
||||
)
|
||||
_print_wizard_substep("Conflict Strategy")
|
||||
click.echo("Conflict strategy controls what import does when a target resource already exists.")
|
||||
click.echo("fail: stop at the first conflict; previously committed resources are not rolled back.")
|
||||
click.echo("skip: keep the existing target resource and skip importing that resource.")
|
||||
click.echo("update: update the existing target resource in place.")
|
||||
conflict_strategy = click.prompt(
|
||||
"Import conflict strategy. Enter one of: fail, skip, update",
|
||||
type=click.Choice(CONFLICT_STRATEGY_CHOICES),
|
||||
default="update",
|
||||
show_default=True,
|
||||
)
|
||||
return include_secrets, create_tokens, id_strategy, conflict_strategy
|
||||
|
||||
|
||||
def _discover_auto_tools(apps: list[App], include_referenced_tools: bool) -> WizardToolMap:
|
||||
auto_tools: WizardToolMap = {"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}}
|
||||
if not include_referenced_tools:
|
||||
return auto_tools
|
||||
discovery_service = DependencyDiscoveryService()
|
||||
for app in apps:
|
||||
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=False)
|
||||
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
|
||||
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
|
||||
for dependency in discovery_service.discover_from_dsl(dsl):
|
||||
if dependency.kind == DependencyKind.API_TOOL:
|
||||
auto_tools["api_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
|
||||
elif dependency.kind == DependencyKind.WORKFLOW_TOOL:
|
||||
auto_tools["workflow_tools"][dependency.provider_name or dependency.provider_id] = (
|
||||
dependency.provider_id
|
||||
)
|
||||
elif dependency.kind == DependencyKind.MCP_TOOL:
|
||||
auto_tools["mcp_tools"][dependency.provider_name or dependency.provider_id] = dependency.provider_id
|
||||
return auto_tools
|
||||
|
||||
|
||||
def _resolve_auto_tool_names(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolMap:
|
||||
return {
|
||||
"api_tools": _resolve_api_tool_names(tenant_id, auto_tools["api_tools"]),
|
||||
"workflow_tools": _resolve_workflow_tool_names(tenant_id, auto_tools["workflow_tools"]),
|
||||
"mcp_tools": _resolve_mcp_tool_names(tenant_id, auto_tools["mcp_tools"]),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_api_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
||||
resolved: dict[str, str | None] = {}
|
||||
for name, identifier in tools.items():
|
||||
predicates = [ApiToolProvider.name == name]
|
||||
if _is_uuid_string(identifier):
|
||||
predicates.append(ApiToolProvider.id == identifier)
|
||||
provider = db.session.scalar(
|
||||
sa.select(ApiToolProvider).where(
|
||||
ApiToolProvider.tenant_id == tenant_id,
|
||||
sa.or_(*predicates),
|
||||
)
|
||||
)
|
||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_workflow_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
||||
resolved: dict[str, str | None] = {}
|
||||
for name, identifier in tools.items():
|
||||
predicates = [WorkflowToolProvider.name == name]
|
||||
if _is_uuid_string(identifier):
|
||||
predicates.append(WorkflowToolProvider.id == identifier)
|
||||
provider = db.session.scalar(
|
||||
sa.select(WorkflowToolProvider).where(
|
||||
WorkflowToolProvider.tenant_id == tenant_id,
|
||||
sa.or_(*predicates),
|
||||
)
|
||||
)
|
||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_mcp_tool_names(tenant_id: str, tools: dict[str, str | None]) -> dict[str, str | None]:
|
||||
resolved: dict[str, str | None] = {}
|
||||
for name, identifier in tools.items():
|
||||
predicates = [MCPToolProvider.name == name]
|
||||
if identifier:
|
||||
predicates.append(MCPToolProvider.server_identifier == identifier)
|
||||
if _is_uuid_string(identifier):
|
||||
predicates.append(MCPToolProvider.id == identifier)
|
||||
provider = db.session.scalar(
|
||||
sa.select(MCPToolProvider).where(
|
||||
MCPToolProvider.tenant_id == tenant_id,
|
||||
sa.or_(*predicates),
|
||||
)
|
||||
)
|
||||
resolved[provider.name if provider else name] = provider.id if provider else identifier
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_uuid_string(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _print_auto_tools(auto_tools: WizardToolMap) -> None:
|
||||
_print_wizard_step("Automatically Discovered Tools")
|
||||
click.echo("Automatically discovered tools:")
|
||||
_print_auto_tool_category("Custom API tools", auto_tools["api_tools"])
|
||||
_print_auto_tool_category("Workflow tools", auto_tools["workflow_tools"])
|
||||
_print_auto_tool_category("MCP tools", auto_tools["mcp_tools"])
|
||||
|
||||
|
||||
def _print_auto_tool_category(label: str, values: dict[str, str | None]) -> None:
|
||||
click.echo(label)
|
||||
if not values:
|
||||
click.echo("- none")
|
||||
return
|
||||
for name, identifier in sorted(values.items()):
|
||||
click.echo(f"- {_format_tool_name_id(name, identifier)}")
|
||||
|
||||
|
||||
def _prompt_additional_tools(tenant_id: str, auto_tools: WizardToolMap) -> WizardToolSelection:
|
||||
selections: WizardToolSelection = {"api_tools": [], "workflow_tools": [], "mcp_tools": []}
|
||||
_print_wizard_step("Additional Tools")
|
||||
if not click.confirm(
|
||||
"Export additional tools manually? [y/n, default: n]",
|
||||
default=False,
|
||||
show_default=False,
|
||||
):
|
||||
_print_final_tool_selection(auto_tools, selections, {})
|
||||
return selections
|
||||
manual_labels: dict[str, str] = {}
|
||||
api_tool_options = [
|
||||
(tool.name, tool.name, tool.id)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant_id).order_by(ApiToolProvider.name)
|
||||
).all()
|
||||
]
|
||||
selections["api_tools"] = _prompt_tool_category(
|
||||
"Custom API tools",
|
||||
api_tool_options,
|
||||
auto_tools=auto_tools["api_tools"],
|
||||
)
|
||||
manual_labels.update(_selected_tool_labels(api_tool_options, selections["api_tools"]))
|
||||
workflow_tool_options = [
|
||||
(tool.id, tool.name, tool.id)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == tenant_id)
|
||||
.order_by(WorkflowToolProvider.name)
|
||||
).all()
|
||||
]
|
||||
selections["workflow_tools"] = _prompt_tool_category(
|
||||
"Workflow tools",
|
||||
workflow_tool_options,
|
||||
auto_tools=auto_tools["workflow_tools"],
|
||||
)
|
||||
manual_labels.update(_selected_tool_labels(workflow_tool_options, selections["workflow_tools"]))
|
||||
mcp_tool_options = [
|
||||
(tool.id, tool.name, tool.server_identifier)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id).order_by(MCPToolProvider.name)
|
||||
).all()
|
||||
]
|
||||
selections["mcp_tools"] = _prompt_tool_category(
|
||||
"MCP tools",
|
||||
mcp_tool_options,
|
||||
auto_tools=auto_tools["mcp_tools"],
|
||||
)
|
||||
manual_labels.update(_selected_tool_labels(mcp_tool_options, selections["mcp_tools"]))
|
||||
_print_final_tool_selection(auto_tools, selections, manual_labels)
|
||||
return selections
|
||||
|
||||
|
||||
def _selected_tool_labels_for_tenant(tenant_id: str, selected_tools: WizardToolSelection) -> dict[str, str]:
|
||||
labels: dict[str, str] = {}
|
||||
if selected_tools["api_tools"]:
|
||||
labels.update(
|
||||
_selected_tool_labels(
|
||||
[
|
||||
(tool.name, tool.name, tool.id)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(ApiToolProvider)
|
||||
.where(ApiToolProvider.tenant_id == tenant_id)
|
||||
.order_by(ApiToolProvider.name)
|
||||
).all()
|
||||
],
|
||||
selected_tools["api_tools"],
|
||||
)
|
||||
)
|
||||
if selected_tools["workflow_tools"]:
|
||||
labels.update(
|
||||
_selected_tool_labels(
|
||||
[
|
||||
(tool.id, tool.name, tool.id)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == tenant_id)
|
||||
.order_by(WorkflowToolProvider.name)
|
||||
).all()
|
||||
],
|
||||
selected_tools["workflow_tools"],
|
||||
)
|
||||
)
|
||||
if selected_tools["mcp_tools"]:
|
||||
labels.update(
|
||||
_selected_tool_labels(
|
||||
[
|
||||
(tool.id, tool.name, tool.server_identifier)
|
||||
for tool in db.session.scalars(
|
||||
sa.select(MCPToolProvider)
|
||||
.where(MCPToolProvider.tenant_id == tenant_id)
|
||||
.order_by(MCPToolProvider.name)
|
||||
).all()
|
||||
],
|
||||
selected_tools["mcp_tools"],
|
||||
)
|
||||
)
|
||||
return labels
|
||||
|
||||
|
||||
def _selected_tool_labels(options: list[tuple[str, str, str]], selected_values: list[str]) -> dict[str, str]:
|
||||
selected = set(selected_values)
|
||||
return {value: _format_tool_name_id(name, detail) for value, name, detail in options if value in selected}
|
||||
|
||||
|
||||
def _prompt_tool_category(
|
||||
label: str,
|
||||
options: list[tuple[str, str, str]],
|
||||
*,
|
||||
auto_tools: dict[str, str | None],
|
||||
) -> list[str]:
|
||||
if not options:
|
||||
click.echo(f"{label}: none")
|
||||
return []
|
||||
_print_wizard_step(label)
|
||||
for index, (value, name, detail) in enumerate(options, 1):
|
||||
marker = "[auto]" if _is_auto_tool(value, name, detail, auto_tools) else "[ ]"
|
||||
click.echo(f"{index}. {marker} {name} ({detail})")
|
||||
raw = click.prompt(
|
||||
f"Select {label.lower()} by number, comma-separated numbers, all, or empty",
|
||||
default="",
|
||||
show_default=cast(Any, "empty"),
|
||||
)
|
||||
if not raw.strip():
|
||||
return []
|
||||
return parse_index_selection(raw, [value for value, _, _ in options])
|
||||
|
||||
|
||||
def _is_auto_tool(value: str, name: str, detail: str, auto_tools: dict[str, str | None]) -> bool:
|
||||
return name in auto_tools or value in auto_tools or value in auto_tools.values() or detail in auto_tools.values()
|
||||
|
||||
|
||||
def _print_final_tool_selection(
|
||||
auto_tools: WizardToolMap,
|
||||
additional_tools: WizardToolSelection,
|
||||
manual_labels: dict[str, str],
|
||||
) -> None:
|
||||
_print_wizard_step("Final Tool Selection")
|
||||
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
|
||||
|
||||
|
||||
def _print_tool_selection_body(
|
||||
auto_tools: WizardToolMap,
|
||||
additional_tools: WizardToolSelection,
|
||||
manual_labels: dict[str, str],
|
||||
) -> None:
|
||||
click.echo("Final tools to export:")
|
||||
_print_final_tool_category(
|
||||
"Custom API tools",
|
||||
auto_tools["api_tools"],
|
||||
additional_tools["api_tools"],
|
||||
manual_labels,
|
||||
)
|
||||
_print_final_tool_category(
|
||||
"Workflow tools",
|
||||
auto_tools["workflow_tools"],
|
||||
additional_tools["workflow_tools"],
|
||||
manual_labels,
|
||||
)
|
||||
_print_final_tool_category("MCP tools", auto_tools["mcp_tools"], additional_tools["mcp_tools"], manual_labels)
|
||||
|
||||
|
||||
def _print_final_tool_category(
|
||||
label: str,
|
||||
auto_tools: dict[str, str | None],
|
||||
manual_values: list[str],
|
||||
manual_labels: dict[str, str],
|
||||
) -> None:
|
||||
click.echo(label)
|
||||
lines = [f"- [auto] {_format_tool_name_id(name, identifier)}" for name, identifier in sorted(auto_tools.items())]
|
||||
auto_identifiers = {identifier for identifier in auto_tools.values() if identifier}
|
||||
lines.extend(
|
||||
f"- [manual] {manual_labels.get(value, value)}"
|
||||
for value in manual_values
|
||||
if value not in auto_tools and value not in auto_identifiers
|
||||
)
|
||||
if not lines:
|
||||
click.echo("- none")
|
||||
return
|
||||
for line in lines:
|
||||
click.echo(line)
|
||||
|
||||
|
||||
def _format_tool_name_id(name: str, identifier: str | None) -> str:
|
||||
if identifier and identifier != name:
|
||||
return f"{name}: {identifier}"
|
||||
return name
|
||||
|
||||
|
||||
def _confirm_wizard_summary(
|
||||
*,
|
||||
tenant_name: str,
|
||||
app_names: list[str],
|
||||
auto_tools: WizardToolMap,
|
||||
additional_tools: WizardToolSelection,
|
||||
manual_labels: dict[str, str],
|
||||
include_referenced_tools: bool,
|
||||
include_secrets: bool,
|
||||
create_tokens: bool,
|
||||
id_strategy: str,
|
||||
conflict_strategy: str,
|
||||
output_file: str,
|
||||
) -> None:
|
||||
_print_wizard_step("Summary")
|
||||
click.echo("Migration export summary:")
|
||||
click.echo(f"source tenant: {tenant_name}")
|
||||
click.echo(f"selected apps: {len(app_names)}")
|
||||
for app_name in app_names:
|
||||
click.echo(f"- {app_name}")
|
||||
click.echo(f"auto referenced tools: {str(include_referenced_tools).lower()}")
|
||||
_print_tool_selection_body(auto_tools, additional_tools, manual_labels)
|
||||
click.echo(f"include secrets: {str(include_secrets).lower()}")
|
||||
click.echo(f"create app api token on import: {str(create_tokens).lower()}")
|
||||
click.echo(f"id strategy: {id_strategy}")
|
||||
click.echo(f"conflict strategy: {conflict_strategy}")
|
||||
click.echo(f"output path: {output_file}")
|
||||
if not click.confirm("Write migration package? [y/n, default: y]", default=True, show_default=False):
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def _prompt_output_file() -> tuple[str, bool]:
|
||||
default_output = f"migration-data-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
|
||||
output_file = click.prompt("Output path", default=default_output, show_default=True)
|
||||
if output_file.lower() in {"y", "yes", "n", "no"}:
|
||||
raise click.ClickException("Output path must be a file path. Press Enter to use the default path.")
|
||||
overwrite = False
|
||||
if Path(output_file).exists():
|
||||
overwrite = click.confirm(
|
||||
"Output file exists. Overwrite? [y/n, default: n]",
|
||||
default=False,
|
||||
show_default=False,
|
||||
)
|
||||
if not overwrite:
|
||||
raise click.ClickException(f"Output file already exists: {output_file}")
|
||||
return output_file, overwrite
|
||||
|
||||
|
||||
def _with_output_path(context: ReportContext | None, output_path: str) -> ReportContext:
|
||||
if context is None:
|
||||
return ReportContext(output_path=output_path)
|
||||
return ReportContext(
|
||||
output_path=output_path,
|
||||
source_scope=context.source_scope,
|
||||
selected_app_count=context.selected_app_count,
|
||||
include_secrets=context.include_secrets,
|
||||
target_tenant=context.target_tenant,
|
||||
operator_email=context.operator_email,
|
||||
app_api_tokens_created=context.app_api_tokens_created,
|
||||
app_api_tokens_reused=context.app_api_tokens_reused,
|
||||
id_mapping_count=context.id_mapping_count,
|
||||
id_mappings=context.id_mappings,
|
||||
)
|
||||
|
||||
|
||||
def _render_report(report_items: list[ResourceReportItem], *, context: ReportContext | None = None) -> None:
|
||||
for line in MigrationReportService().render(report_items, context=context):
|
||||
click.echo(line)
|
||||
@ -30,7 +30,7 @@ def vdb_migrate(scope: str):
|
||||
|
||||
def migrate_annotation_vector_database():
|
||||
"""
|
||||
Migrate annotation data to target vector database.
|
||||
Migrate annotation datas to target vector database .
|
||||
"""
|
||||
click.echo(click.style("Starting annotation data migration.", fg="green"))
|
||||
create_count = 0
|
||||
@ -140,7 +140,7 @@ def migrate_annotation_vector_database():
|
||||
|
||||
def migrate_knowledge_vector_database():
|
||||
"""
|
||||
Migrate vector database data to target vector database.
|
||||
Migrate vector database datas to target vector database .
|
||||
"""
|
||||
click.echo(click.style("Starting vector database migration.", fg="green"))
|
||||
create_count = 0
|
||||
|
||||
@ -5,7 +5,7 @@ from uuid import UUID
|
||||
from flask import request
|
||||
from flask_restx import Resource, marshal
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import String, case, cast, func, literal, or_, select
|
||||
from sqlalchemy import String, cast, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@ -169,17 +169,12 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
# Use database-specific methods for JSON array search
|
||||
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
|
||||
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
|
||||
# Feed the set-returning function a JSON array in every row. Filtering in
|
||||
# the subquery is not enough because PostgreSQL can still evaluate the
|
||||
# SRF on scalar JSON before applying the predicate.
|
||||
keywords_jsonb = cast(DocumentSegment.keywords, JSONB)
|
||||
keywords_array = case(
|
||||
(func.jsonb_typeof(keywords_jsonb) == "array", keywords_jsonb),
|
||||
else_=cast(literal("[]"), JSONB),
|
||||
)
|
||||
# Guard with jsonb_typeof to avoid "cannot extract elements from a scalar" error
|
||||
# when keywords is null or a non-array JSON value.
|
||||
keywords_condition = func.array_to_string(
|
||||
func.array(
|
||||
select(func.jsonb_array_elements_text(keywords_array))
|
||||
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
|
||||
.where(func.jsonb_typeof(cast(DocumentSegment.keywords, JSONB)) == "array")
|
||||
.correlate(DocumentSegment)
|
||||
.scalar_subquery()
|
||||
),
|
||||
|
||||
@ -863,7 +863,7 @@ class ToolManager:
|
||||
return controller
|
||||
|
||||
@classmethod
|
||||
def user_get_api_provider(cls, provider: str, tenant_id: str, mask: bool = True):
|
||||
def user_get_api_provider(cls, provider: str, tenant_id: str):
|
||||
"""
|
||||
get api provider
|
||||
"""
|
||||
@ -902,10 +902,8 @@ class ToolManager:
|
||||
tenant_id=tenant_id,
|
||||
controller=controller,
|
||||
)
|
||||
if mask:
|
||||
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
|
||||
else:
|
||||
masked_credentials = encrypter.decrypt(credentials)
|
||||
|
||||
masked_credentials = encrypter.mask_plugin_credentials(encrypter.decrypt(credentials))
|
||||
|
||||
try:
|
||||
icon = emoji_icon_adapter.validate_json(provider_obj.icon)
|
||||
|
||||
@ -6,7 +6,7 @@ from json.decoder import JSONDecodeError
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import httpx
|
||||
from flask import has_request_context, request
|
||||
from flask import request
|
||||
from yaml import YAMLError, safe_load
|
||||
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
@ -44,7 +44,7 @@ class ApiBasedToolSchemaParser:
|
||||
raise ToolProviderNotFoundError("No server found in the openapi yaml.")
|
||||
|
||||
server_url = openapi["servers"][0]["url"]
|
||||
request_env = request.headers.get("X-Request-Env") if has_request_context() else None
|
||||
request_env = request.headers.get("X-Request-Env")
|
||||
if request_env:
|
||||
matched_servers = [server["url"] for server in openapi["servers"] if server["env"] == request_env]
|
||||
server_url = matched_servers[0] if matched_servers else server_url
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import logging
|
||||
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.errors import ToolProviderNotFoundError
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.configuration import ToolParameterConfigurationManager
|
||||
from core.workflow.human_input_adapter import adapt_node_config_for_graph
|
||||
@ -39,14 +38,6 @@ def handle(sender, **kwargs):
|
||||
identity_id=f"WORKFLOW.{app.id}.{node_data.get('id')}",
|
||||
)
|
||||
manager.delete_tool_parameters_cache()
|
||||
except ToolProviderNotFoundError as exc:
|
||||
logger.info(
|
||||
"Skipped deleting tool parameters cache for workflow %s node %s "
|
||||
"because tool provider is missing: %s",
|
||||
app.id,
|
||||
node_data.get("id"),
|
||||
exc,
|
||||
)
|
||||
except Exception:
|
||||
# tool dose not exist
|
||||
logger.exception(
|
||||
|
||||
@ -15,18 +15,14 @@ def init_app(app: DifyApp):
|
||||
data_migrate,
|
||||
delete_archived_workflow_runs,
|
||||
export_app_messages,
|
||||
export_migration_data,
|
||||
export_migration_data_template,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
file_usage,
|
||||
fix_app_site_missing,
|
||||
import_migration_data,
|
||||
install_plugins,
|
||||
install_rag_pipeline_plugins,
|
||||
migrate_data_for_plugin,
|
||||
migrate_oss,
|
||||
migration_data_wizard,
|
||||
old_metadata_migration,
|
||||
remove_orphaned_files_on_storage,
|
||||
reset_email,
|
||||
@ -74,10 +70,6 @@ def init_app(app: DifyApp):
|
||||
clean_workflow_runs,
|
||||
clean_expired_messages,
|
||||
export_app_messages,
|
||||
export_migration_data,
|
||||
export_migration_data_template,
|
||||
import_migration_data,
|
||||
migration_data_wizard,
|
||||
]
|
||||
for cmd in cmds_to_register:
|
||||
app.cli.add_command(cmd)
|
||||
|
||||
@ -97,7 +97,6 @@ class AppDslService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
app_id: str | None = None,
|
||||
import_app_id: str | None = None,
|
||||
) -> Import:
|
||||
"""Import an app from YAML content or URL."""
|
||||
import_id = str(uuid.uuid4())
|
||||
@ -263,7 +262,6 @@ class AppDslService:
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
dependencies=check_dependencies_pending_data,
|
||||
import_app_id=import_app_id,
|
||||
)
|
||||
|
||||
draft_var_srv = WorkflowDraftVariableService(session=self._session)
|
||||
@ -387,7 +385,6 @@ class AppDslService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
dependencies: list[PluginDependency] | None = None,
|
||||
import_app_id: str | None = None,
|
||||
) -> App:
|
||||
"""Create a new app or update an existing one."""
|
||||
app_data = data.get("app", {})
|
||||
@ -420,7 +417,7 @@ class AppDslService:
|
||||
|
||||
# Create new app
|
||||
app = App()
|
||||
app.id = import_app_id or str(uuid4())
|
||||
app.id = str(uuid4())
|
||||
app.tenant_id = account.current_tenant_id
|
||||
app.mode = app_mode
|
||||
app.name = name or app_data.get("name", "")
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
ExportSelection,
|
||||
IdStrategy,
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
MigrationPackage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConflictStrategy",
|
||||
"ExportSelection",
|
||||
"IdStrategy",
|
||||
"ImportOptions",
|
||||
"MigrationDataError",
|
||||
"MigrationPackage",
|
||||
]
|
||||
@ -1,92 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from services.data_migration.entities import DependencyKind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredDependency:
|
||||
kind: DependencyKind
|
||||
provider_id: str
|
||||
provider_name: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class DependencyDiscoveryService:
|
||||
def discover_from_dsl(self, dsl: dict[str, Any]) -> list[DiscoveredDependency]:
|
||||
seen: set[tuple[DependencyKind, str]] = set()
|
||||
result: list[DiscoveredDependency] = []
|
||||
for node in self._nodes_from_dsl(dsl):
|
||||
data = node.get("data", {}) if isinstance(node, dict) else {}
|
||||
for dependency in self._dependencies_from_node(data):
|
||||
key = (dependency.kind, dependency.provider_id)
|
||||
if dependency.provider_id and key not in seen:
|
||||
seen.add(key)
|
||||
result.append(dependency)
|
||||
return result
|
||||
|
||||
def _nodes_from_dsl(self, dsl: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
nodes: list[dict[str, Any]] = []
|
||||
graph = dsl.get("graph") if isinstance(dsl, dict) else None
|
||||
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
|
||||
nodes.extend(node for node in graph["nodes"] if isinstance(node, dict))
|
||||
workflow = dsl.get("workflow") if isinstance(dsl, dict) else None
|
||||
workflow_graph = workflow.get("graph") if isinstance(workflow, dict) else None
|
||||
if isinstance(workflow_graph, dict) and isinstance(workflow_graph.get("nodes"), list):
|
||||
nodes.extend(node for node in workflow_graph["nodes"] if isinstance(node, dict))
|
||||
return nodes
|
||||
|
||||
def _dependencies_from_node(self, data: dict[str, Any]) -> list[DiscoveredDependency]:
|
||||
dependencies: list[DiscoveredDependency] = []
|
||||
node_type = data.get("type")
|
||||
if node_type == "tool":
|
||||
dependency = self._from_tool_config(data, source="tool_node")
|
||||
if dependency:
|
||||
dependencies.append(dependency)
|
||||
if node_type == "agent":
|
||||
for tool_config in self._agent_tool_configs(data):
|
||||
if isinstance(tool_config, dict):
|
||||
dependency = self._from_tool_config(tool_config, source="agent_node")
|
||||
if dependency:
|
||||
dependencies.append(dependency)
|
||||
return dependencies
|
||||
|
||||
def _agent_tool_configs(self, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
configs = data.get("tools")
|
||||
if isinstance(configs, list):
|
||||
return [config for config in configs if isinstance(config, dict)]
|
||||
agent_parameters = data.get("agent_parameters")
|
||||
if not isinstance(agent_parameters, dict):
|
||||
return []
|
||||
tools_parameter = agent_parameters.get("tools")
|
||||
if not isinstance(tools_parameter, dict):
|
||||
return []
|
||||
value = tools_parameter.get("value", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [config for config in value if isinstance(config, dict)]
|
||||
|
||||
def _from_tool_config(self, config: dict[str, Any], *, source: str) -> DiscoveredDependency | None:
|
||||
provider_id = config.get("provider_id") or config.get("provider_name") or config.get("provider")
|
||||
if not provider_id:
|
||||
return None
|
||||
provider_type = str(config.get("provider_type") or config.get("type") or "")
|
||||
kind = self._kind_from_provider_type(provider_type)
|
||||
return DiscoveredDependency(
|
||||
kind=kind,
|
||||
provider_id=str(provider_id),
|
||||
provider_name=config.get("provider_name"),
|
||||
source=source,
|
||||
)
|
||||
|
||||
def _kind_from_provider_type(self, provider_type: str) -> DependencyKind:
|
||||
normalized = provider_type.lower()
|
||||
if normalized in {"api", "custom", "api_tool"}:
|
||||
return DependencyKind.API_TOOL
|
||||
if normalized in {"workflow", "workflow_tool"}:
|
||||
return DependencyKind.WORKFLOW_TOOL
|
||||
if normalized == "mcp":
|
||||
return DependencyKind.MCP_TOOL
|
||||
return DependencyKind.BUILTIN_OR_PLUGIN_TOOL
|
||||
@ -1,241 +0,0 @@
|
||||
"""Typed entities for versioned cross-environment migration packages.
|
||||
|
||||
This module is intentionally side-effect free. It owns only value objects and
|
||||
validation for migration package/config shapes; command output and database I/O
|
||||
belong in adapter and service modules built on top of these entities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
|
||||
class MigrationDataError(ValueError):
|
||||
"""Raised when migration config or package data is invalid."""
|
||||
|
||||
|
||||
class IdStrategy(StrEnum):
|
||||
PRESERVE_ID = "preserve-id"
|
||||
GENERATE_NEW_ID = "generate-new-id"
|
||||
|
||||
|
||||
class ConflictStrategy(StrEnum):
|
||||
FAIL = "fail"
|
||||
SKIP = "skip"
|
||||
UPDATE = "update"
|
||||
|
||||
|
||||
class ResourceType(StrEnum):
|
||||
WORKFLOW = "workflow"
|
||||
API_TOOL = "api_tool"
|
||||
WORKFLOW_TOOL = "workflow_tool"
|
||||
MCP_TOOL = "mcp_tool"
|
||||
DEPENDENCY = "dependency"
|
||||
|
||||
|
||||
class DependencyKind(StrEnum):
|
||||
API_TOOL = "api_tool"
|
||||
WORKFLOW_TOOL = "workflow_tool"
|
||||
MCP_TOOL = "mcp_tool"
|
||||
BUILTIN_OR_PLUGIN_TOOL = "builtin_or_plugin_tool"
|
||||
UNRESOLVED = "unresolved"
|
||||
|
||||
|
||||
class TargetTenantSelector(TypedDict, total=False):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
def _parse_target_tenant(value: Any) -> TargetTenantSelector | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
raise MigrationDataError("metadata.target_tenant must be an object when provided.")
|
||||
target: TargetTenantSelector = {}
|
||||
target_id = value.get("id")
|
||||
if target_id is not None:
|
||||
if not isinstance(target_id, str):
|
||||
raise MigrationDataError("metadata.target_tenant.id must be a string.")
|
||||
target["id"] = target_id
|
||||
target_name = value.get("name")
|
||||
if target_name is not None:
|
||||
if not isinstance(target_name, str):
|
||||
raise MigrationDataError("metadata.target_tenant.name must be a string.")
|
||||
target["name"] = target_name
|
||||
unsupported_keys = sorted(set(value.keys()) - {"id", "name"})
|
||||
if unsupported_keys:
|
||||
raise MigrationDataError(f"metadata.target_tenant contains unsupported fields: {unsupported_keys}")
|
||||
return target
|
||||
|
||||
|
||||
def _parse_package_section(value: Any, section: str) -> list[dict[str, Any]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MigrationDataError(f"Migration package field '{section}' must be a list.")
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise MigrationDataError(f"Migration package field '{section}' must contain only objects.")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceTenant:
|
||||
id: str
|
||||
name: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> SourceTenant:
|
||||
return cls(id=str(value.get("id", "")), name=str(value.get("name", "")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportOptions:
|
||||
create_app_api_token_on_import: bool = False
|
||||
id_strategy: IdStrategy = IdStrategy.PRESERVE_ID
|
||||
conflict_strategy: ConflictStrategy = ConflictStrategy.FAIL
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any] | None) -> ImportOptions:
|
||||
value = value or {}
|
||||
try:
|
||||
id_strategy = IdStrategy(value.get("id_strategy", IdStrategy.PRESERVE_ID))
|
||||
except ValueError as exc:
|
||||
raise MigrationDataError(f"Unsupported import_options.id_strategy: {value.get('id_strategy')}") from exc
|
||||
try:
|
||||
conflict_strategy = ConflictStrategy(value.get("conflict_strategy", ConflictStrategy.FAIL))
|
||||
except ValueError as exc:
|
||||
raise MigrationDataError(
|
||||
f"Unsupported import_options.conflict_strategy: {value.get('conflict_strategy')}"
|
||||
) from exc
|
||||
return cls(
|
||||
create_app_api_token_on_import=bool(value.get("create_app_api_token_on_import", False)),
|
||||
id_strategy=id_strategy,
|
||||
conflict_strategy=conflict_strategy,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationMetadata:
|
||||
version: str
|
||||
source_scope: Literal["single"]
|
||||
source_tenants: list[SourceTenant]
|
||||
target_tenant: TargetTenantSelector | None = None
|
||||
created_at: str | None = None
|
||||
include_secrets: bool = False
|
||||
import_options: ImportOptions = field(default_factory=ImportOptions)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> MigrationMetadata:
|
||||
version = value.get("version")
|
||||
if not version:
|
||||
raise MigrationDataError("Migration package must include metadata.version.")
|
||||
source_scope = value.get("source_scope", "single")
|
||||
if source_scope != "single":
|
||||
raise MigrationDataError(f"Unsupported source_scope: {source_scope}")
|
||||
source_tenants = [
|
||||
SourceTenant.from_mapping(item) for item in value.get("source_tenants", []) if isinstance(item, dict)
|
||||
]
|
||||
return cls(
|
||||
version=str(version),
|
||||
source_scope="single",
|
||||
source_tenants=source_tenants,
|
||||
target_tenant=_parse_target_tenant(value.get("target_tenant")),
|
||||
created_at=value.get("created_at"),
|
||||
include_secrets=bool(value.get("include_secrets", False)),
|
||||
import_options=ImportOptions.from_mapping(value.get("import_options")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationPackage:
|
||||
metadata: MigrationMetadata
|
||||
workflows: list[dict[str, Any]] = field(default_factory=list)
|
||||
tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
workflow_tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
mcp_tools: list[dict[str, Any]] = field(default_factory=list)
|
||||
dependencies: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> MigrationPackage:
|
||||
metadata_value = value.get("metadata")
|
||||
if not isinstance(metadata_value, dict):
|
||||
raise MigrationDataError("Migration package must include metadata.version.")
|
||||
return cls(
|
||||
metadata=MigrationMetadata.from_mapping(metadata_value),
|
||||
workflows=_parse_package_section(value.get("workflows"), "workflows"),
|
||||
tools=_parse_package_section(value.get("tools"), "tools"),
|
||||
workflow_tools=_parse_package_section(value.get("workflow_tools"), "workflow_tools"),
|
||||
mcp_tools=_parse_package_section(value.get("mcp_tools"), "mcp_tools"),
|
||||
dependencies=_parse_package_section(value.get("dependencies"), "dependencies"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportSelection:
|
||||
source_tenant_name: str
|
||||
app_ids: list[str]
|
||||
source_tenant_id: str | None = None
|
||||
export_all_apps: bool = False
|
||||
include_referenced_tools: bool = True
|
||||
additional_api_tools: list[str] = field(default_factory=list)
|
||||
additional_workflow_tools: list[str] = field(default_factory=list)
|
||||
additional_mcp_tools: list[str] = field(default_factory=list)
|
||||
include_secrets: bool = False
|
||||
import_options: ImportOptions = field(default_factory=ImportOptions)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceReportItem:
|
||||
resource_type: ResourceType
|
||||
identifier: str
|
||||
name: str | None
|
||||
status: str
|
||||
message: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceIdMapping:
|
||||
resource_type: ResourceType
|
||||
name: str | None
|
||||
source_id: str
|
||||
target_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportResult:
|
||||
package: MigrationPackage
|
||||
report_items: list[ResourceReportItem]
|
||||
report_context: ReportContext | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportTarget:
|
||||
tenant_id: str
|
||||
tenant_name: str
|
||||
operator_id: str
|
||||
operator_email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
report_items: list[ResourceReportItem]
|
||||
id_mapping: dict[str, str] = field(default_factory=dict)
|
||||
report_context: ReportContext | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportContext:
|
||||
output_path: str | None = None
|
||||
source_scope: str | None = None
|
||||
selected_app_count: int | None = None
|
||||
include_secrets: bool | None = None
|
||||
target_tenant: str | None = None
|
||||
operator_email: str | None = None
|
||||
app_api_tokens_created: int = 0
|
||||
app_api_tokens_reused: int = 0
|
||||
id_mapping_count: int = 0
|
||||
id_mappings: dict[str, str] = field(default_factory=dict)
|
||||
id_mapping_details: list[ResourceIdMapping] = field(default_factory=list)
|
||||
@ -1,492 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from models import Account, Tenant
|
||||
from models.account import TenantAccountJoin
|
||||
from models.model import App
|
||||
from models.tools import MCPToolProvider
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService, DiscoveredDependency
|
||||
from services.data_migration.entities import (
|
||||
DependencyKind,
|
||||
ExportResult,
|
||||
ExportSelection,
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
ReportContext,
|
||||
ResourceReportItem,
|
||||
ResourceType,
|
||||
)
|
||||
from services.data_migration.package_service import MigrationPackageService
|
||||
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
|
||||
|
||||
SUPPORTED_APP_MODES = {"workflow", "advanced-chat"}
|
||||
|
||||
|
||||
class ExportConfigParser:
|
||||
def parse(self, data: dict[str, Any]) -> ExportSelection:
|
||||
if not isinstance(data, dict):
|
||||
raise MigrationDataError("Export config JSON must be an object.")
|
||||
|
||||
source_tenant = self._source_tenant(data)
|
||||
source_tenant_name = self._source_tenant_name(source_tenant, data)
|
||||
apps = self._mapping(data.get("apps"), field_name="apps")
|
||||
self._validate_source_scope(data)
|
||||
self._validate_app_modes(apps.get("modes", []))
|
||||
|
||||
additional_tools = self._mapping(data.get("additional_tools"), field_name="additional_tools")
|
||||
return ExportSelection(
|
||||
source_tenant_name=source_tenant_name,
|
||||
app_ids=self._string_list(apps.get("ids", data.get("workflows", [])), field_name="apps.ids"),
|
||||
source_tenant_id=source_tenant.get("id"),
|
||||
export_all_apps=bool(apps.get("all", data.get("export_all_workflows", False))),
|
||||
include_referenced_tools=bool(data.get("include_referenced_tools", True)),
|
||||
additional_api_tools=self._string_list(
|
||||
additional_tools.get("api_tools", data.get("tools", [])), field_name="additional_tools.api_tools"
|
||||
),
|
||||
additional_workflow_tools=self._string_list(
|
||||
additional_tools.get("workflow_tools", data.get("workflow_tools", [])),
|
||||
field_name="additional_tools.workflow_tools",
|
||||
),
|
||||
additional_mcp_tools=self._string_list(
|
||||
additional_tools.get("mcp_tools", data.get("mcp_tools", [])),
|
||||
field_name="additional_tools.mcp_tools",
|
||||
),
|
||||
include_secrets=bool(data.get("include_secrets", False)),
|
||||
import_options=ImportOptions.from_mapping(data.get("import_options")),
|
||||
)
|
||||
|
||||
def _source_tenant(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
if "source_tenant" in data:
|
||||
return self._mapping(data.get("source_tenant"), field_name="source_tenant")
|
||||
return {}
|
||||
|
||||
def _source_tenant_name(self, source_tenant: dict[str, Any], data: dict[str, Any]) -> str:
|
||||
if source_tenant:
|
||||
source_tenant_name = source_tenant.get("name")
|
||||
if not source_tenant_name:
|
||||
raise MigrationDataError("Export config must include source_tenant.name.")
|
||||
return str(source_tenant_name)
|
||||
source_tenant_name = data.get("tenant_name")
|
||||
if not source_tenant_name:
|
||||
raise MigrationDataError("Export config must include source_tenant.name.")
|
||||
return str(source_tenant_name)
|
||||
|
||||
def _validate_source_scope(self, data: dict[str, Any]) -> None:
|
||||
source_tenant = data.get("source_tenant")
|
||||
if not isinstance(source_tenant, dict):
|
||||
return
|
||||
mode = source_tenant.get("mode", "single")
|
||||
if mode != "single":
|
||||
raise MigrationDataError(f"Unsupported source_tenant.mode: {mode}")
|
||||
|
||||
def _validate_app_modes(self, modes: Any) -> None:
|
||||
app_modes = self._string_list(modes, field_name="apps.modes") if modes else []
|
||||
unsupported_modes = sorted(set(app_modes) - SUPPORTED_APP_MODES)
|
||||
if unsupported_modes:
|
||||
raise MigrationDataError(f"Unsupported app modes for export: {unsupported_modes}")
|
||||
|
||||
def _mapping(self, value: Any, *, field_name: str) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise MigrationDataError(f"Export config field '{field_name}' must be an object.")
|
||||
return value
|
||||
|
||||
def _string_list(self, value: Any, *, field_name: str) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise MigrationDataError(f"Export config field '{field_name}' must be a list.")
|
||||
return [str(item) for item in value]
|
||||
|
||||
|
||||
class MigrationExportService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
package_service: MigrationPackageService | None = None,
|
||||
dependency_discovery_service: DependencyDiscoveryService | None = None,
|
||||
) -> None:
|
||||
self.package_service = package_service or MigrationPackageService()
|
||||
self.dependency_discovery_service = dependency_discovery_service or DependencyDiscoveryService()
|
||||
|
||||
def export(self, selection: ExportSelection) -> ExportResult:
|
||||
tenant = self._get_tenant(selection)
|
||||
package = self.package_service.build_empty_package(
|
||||
source_tenant_id=tenant.id,
|
||||
source_tenant_name=tenant.name,
|
||||
include_secrets=selection.include_secrets,
|
||||
import_options=selection.import_options,
|
||||
)
|
||||
report_items: list[ResourceReportItem] = []
|
||||
discovered_dependencies: list[DiscoveredDependency] = []
|
||||
|
||||
apps = self._selected_apps(tenant.id, selection)
|
||||
exported_app_ids = {app.id for app in apps}
|
||||
for app in apps:
|
||||
dsl_content = AppDslService.export_dsl(app_model=app, include_secret=selection.include_secrets)
|
||||
package.workflows.append(
|
||||
{
|
||||
"id": app.id,
|
||||
"name": app.name,
|
||||
"mode": app.mode.value if hasattr(app.mode, "value") else app.mode,
|
||||
"dsl": dsl_content,
|
||||
"source_tenant_id": tenant.id,
|
||||
"create_app_api_token_on_import": selection.import_options.create_app_api_token_on_import,
|
||||
}
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.WORKFLOW, app.id, app.name, "exported"))
|
||||
if selection.include_referenced_tools:
|
||||
discovered_dependencies.extend(self._discover_dependencies(dsl_content))
|
||||
|
||||
self._export_api_tools(
|
||||
tenant.id,
|
||||
self._provider_ids(selection.additional_api_tools, discovered_dependencies, DependencyKind.API_TOOL),
|
||||
include_secrets=selection.include_secrets,
|
||||
exported_tools=package.tools,
|
||||
report_items=report_items,
|
||||
)
|
||||
self._export_workflow_tools(
|
||||
tenant,
|
||||
self._provider_ids(
|
||||
selection.additional_workflow_tools, discovered_dependencies, DependencyKind.WORKFLOW_TOOL
|
||||
),
|
||||
exported_app_ids=exported_app_ids,
|
||||
exported_workflow_tools=package.workflow_tools,
|
||||
dependencies=package.dependencies,
|
||||
report_items=report_items,
|
||||
)
|
||||
self._export_mcp_tools(
|
||||
tenant_id=tenant.id,
|
||||
provider_ids=self._provider_ids(
|
||||
selection.additional_mcp_tools,
|
||||
discovered_dependencies,
|
||||
DependencyKind.MCP_TOOL,
|
||||
),
|
||||
include_secrets=selection.include_secrets,
|
||||
exported_mcp_tools=package.mcp_tools,
|
||||
dependencies=package.dependencies,
|
||||
report_items=report_items,
|
||||
)
|
||||
self._record_dependency_metadata(
|
||||
self._dependencies_by_kind(discovered_dependencies, DependencyKind.BUILTIN_OR_PLUGIN_TOOL),
|
||||
package.dependencies,
|
||||
report_items,
|
||||
)
|
||||
return ExportResult(
|
||||
package=package,
|
||||
report_items=report_items,
|
||||
report_context=ReportContext(
|
||||
source_scope=package.metadata.source_scope,
|
||||
selected_app_count=len(apps),
|
||||
include_secrets=selection.include_secrets,
|
||||
),
|
||||
)
|
||||
|
||||
def _get_tenant(self, selection: ExportSelection) -> Tenant:
|
||||
if selection.source_tenant_id:
|
||||
tenant = db.session.get(Tenant, selection.source_tenant_id)
|
||||
if tenant is None:
|
||||
raise MigrationDataError(f"Source tenant not found: {selection.source_tenant_id}")
|
||||
if tenant.name != selection.source_tenant_name:
|
||||
raise MigrationDataError(
|
||||
f"Source tenant id/name mismatch: {selection.source_tenant_id} / {selection.source_tenant_name}"
|
||||
)
|
||||
return tenant
|
||||
tenants = list(db.session.scalars(sa.select(Tenant).where(Tenant.name == selection.source_tenant_name)).all())
|
||||
if not tenants:
|
||||
raise MigrationDataError(f"Source tenant not found: {selection.source_tenant_name}")
|
||||
if len(tenants) > 1:
|
||||
raise MigrationDataError(
|
||||
f"Source tenant name is ambiguous; use source_tenant.id: {selection.source_tenant_name}"
|
||||
)
|
||||
return tenants[0]
|
||||
|
||||
def _selected_apps(self, tenant_id: str, selection: ExportSelection) -> list[App]:
|
||||
query = sa.select(App).where(App.tenant_id == tenant_id, App.mode.in_(SUPPORTED_APP_MODES))
|
||||
if not selection.export_all_apps:
|
||||
if not selection.app_ids:
|
||||
return []
|
||||
query = query.where(App.id.in_(selection.app_ids))
|
||||
apps = list(db.session.scalars(query).all())
|
||||
if not selection.export_all_apps and len(apps) != len(set(selection.app_ids)):
|
||||
found_ids = {app.id for app in apps}
|
||||
missing_ids = [app_id for app_id in selection.app_ids if app_id not in found_ids]
|
||||
raise MigrationDataError(
|
||||
f"Selected app IDs not found in source tenant or unsupported app mode: {missing_ids}"
|
||||
)
|
||||
return apps
|
||||
|
||||
def _discover_dependencies(self, dsl_content: str | dict[str, Any]) -> list[DiscoveredDependency]:
|
||||
if isinstance(dsl_content, dict):
|
||||
dsl = dsl_content
|
||||
else:
|
||||
raw_dsl = yaml.safe_load(dsl_content) if dsl_content else {}
|
||||
dsl = raw_dsl if isinstance(raw_dsl, dict) else {}
|
||||
return self.dependency_discovery_service.discover_from_dsl(dsl)
|
||||
|
||||
def _export_api_tools(
|
||||
self,
|
||||
tenant_id: str,
|
||||
provider_ids: Iterable[str],
|
||||
*,
|
||||
include_secrets: bool,
|
||||
exported_tools: list[dict[str, Any]],
|
||||
report_items: list[ResourceReportItem],
|
||||
) -> None:
|
||||
for provider_id in self._dedupe(provider_ids):
|
||||
try:
|
||||
tool_data = ToolManager.user_get_api_provider(
|
||||
provider=provider_id,
|
||||
tenant_id=tenant_id,
|
||||
mask=not include_secrets,
|
||||
)
|
||||
if not include_secrets:
|
||||
tool_data.pop("credentials", None)
|
||||
tool_data.pop("tools", None)
|
||||
tool_data["provider_name"] = provider_id
|
||||
tool_data["source_tenant_id"] = tenant_id
|
||||
exported_tools.append(tool_data)
|
||||
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_id, provider_id, "exported"))
|
||||
except Exception as exc:
|
||||
report_items.append(
|
||||
ResourceReportItem(ResourceType.API_TOOL, provider_id, provider_id, "unresolved", str(exc))
|
||||
)
|
||||
|
||||
def _export_workflow_tools(
|
||||
self,
|
||||
tenant: Tenant,
|
||||
provider_ids: Iterable[str],
|
||||
*,
|
||||
exported_app_ids: set[str],
|
||||
exported_workflow_tools: list[dict[str, Any]],
|
||||
dependencies: list[dict[str, Any]],
|
||||
report_items: list[ResourceReportItem],
|
||||
) -> None:
|
||||
provider_ids = self._dedupe(provider_ids)
|
||||
if not provider_ids:
|
||||
return
|
||||
owner = self._get_tenant_owner(tenant.id)
|
||||
if owner is None:
|
||||
for provider_id in provider_ids:
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
provider_id,
|
||||
provider_id,
|
||||
"unresolved",
|
||||
f"No owner account found for source tenant: {tenant.name}",
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
for provider_id in provider_ids:
|
||||
try:
|
||||
tool_data = WorkflowToolManageService.get_workflow_tool_by_tool_id(
|
||||
user_id=owner.id,
|
||||
tenant_id=tenant.id,
|
||||
workflow_tool_id=provider_id,
|
||||
)
|
||||
tool_info = jsonable_encoder(tool_data)
|
||||
tool_info["id"] = provider_id
|
||||
tool_info["app_id"] = tool_info.get("workflow_app_id")
|
||||
tool_info["source_tenant_id"] = tenant.id
|
||||
for field_name in ("workflow_tool_id", "workflow_app_id", "tool"):
|
||||
tool_info.pop(field_name, None)
|
||||
exported_workflow_tools.append(tool_info)
|
||||
if tool_info.get("app_id") not in exported_app_ids:
|
||||
workflow_app_id = str(tool_info.get("app_id") or "")
|
||||
workflow_app = db.session.get(App, workflow_app_id) if workflow_app_id else None
|
||||
self._record_dependency_metadata(
|
||||
[
|
||||
DiscoveredDependency(
|
||||
DependencyKind.WORKFLOW_TOOL,
|
||||
workflow_app_id,
|
||||
provider_name=workflow_app.name if workflow_app else tool_info.get("name"),
|
||||
source="workflow_tool_app",
|
||||
)
|
||||
],
|
||||
dependencies,
|
||||
report_items,
|
||||
)
|
||||
report_items.append(
|
||||
ResourceReportItem(ResourceType.WORKFLOW_TOOL, provider_id, tool_info.get("name"), "exported")
|
||||
)
|
||||
except Exception as exc:
|
||||
report_items.append(
|
||||
ResourceReportItem(ResourceType.WORKFLOW_TOOL, provider_id, provider_id, "unresolved", str(exc))
|
||||
)
|
||||
|
||||
def _get_tenant_owner(self, tenant_id: str) -> Account | None:
|
||||
return db.session.scalar(
|
||||
sa.select(Account)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(TenantAccountJoin.tenant_id == tenant_id, TenantAccountJoin.role == "owner")
|
||||
.order_by(TenantAccountJoin.created_at.asc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def _export_mcp_tools(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
provider_ids: Iterable[str],
|
||||
include_secrets: bool,
|
||||
exported_mcp_tools: list[dict[str, Any]],
|
||||
dependencies: list[dict[str, Any]],
|
||||
report_items: list[ResourceReportItem],
|
||||
) -> None:
|
||||
for provider_id in self._dedupe(provider_ids):
|
||||
if not include_secrets:
|
||||
self._record_dependency_metadata(
|
||||
[DiscoveredDependency(DependencyKind.MCP_TOOL, provider_id, source="mcp_provider")],
|
||||
dependencies,
|
||||
report_items,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
provider = self._get_mcp_provider(tenant_id, provider_id)
|
||||
exported_mcp_tools.append(self._serialize_mcp_provider(provider))
|
||||
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, provider_id, provider.name, "exported"))
|
||||
except Exception as exc:
|
||||
report_items.append(
|
||||
ResourceReportItem(ResourceType.MCP_TOOL, provider_id, provider_id, "unresolved", str(exc))
|
||||
)
|
||||
|
||||
def _get_mcp_provider(self, tenant_id: str, provider_id: str) -> MCPToolProvider:
|
||||
predicates = [MCPToolProvider.server_identifier == provider_id]
|
||||
if self._is_uuid_string(provider_id):
|
||||
predicates.append(MCPToolProvider.id == provider_id)
|
||||
provider = db.session.scalar(
|
||||
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, sa.or_(*predicates))
|
||||
)
|
||||
if provider is None:
|
||||
raise MigrationDataError(f"MCP provider not found: {provider_id}")
|
||||
return provider
|
||||
|
||||
def _is_uuid_string(self, value: str) -> bool:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _serialize_mcp_provider(self, provider: MCPToolProvider) -> dict[str, Any]:
|
||||
provider_entity = provider.to_entity()
|
||||
provider_icon = provider_entity.provider_icon
|
||||
if isinstance(provider_icon, dict):
|
||||
icon = provider_icon.get("content")
|
||||
icon_background = provider_icon.get("background")
|
||||
icon_type = "emoji"
|
||||
else:
|
||||
icon = provider_icon
|
||||
icon_background = None
|
||||
icon_type = "url"
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"server_url": provider_entity.decrypt_server_url(),
|
||||
"server_identifier": provider.server_identifier,
|
||||
"icon": icon,
|
||||
"icon_background": icon_background,
|
||||
"icon_type": icon_type,
|
||||
"configuration": {"timeout": provider.timeout, "sse_read_timeout": provider.sse_read_timeout},
|
||||
"headers": provider_entity.decrypt_headers(),
|
||||
"authentication": self._serialize_mcp_authentication(provider_entity.decrypt_authentication()),
|
||||
"tools": provider.tool_dict,
|
||||
"source_tenant_id": provider.tenant_id,
|
||||
}
|
||||
|
||||
def _serialize_mcp_authentication(self, authentication: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if not authentication or not authentication.get("client_id"):
|
||||
return None
|
||||
return {
|
||||
"client_id": authentication["client_id"],
|
||||
"client_secret": authentication.get("client_secret"),
|
||||
}
|
||||
|
||||
def _record_dependency_metadata(
|
||||
self,
|
||||
dependencies_to_record: Iterable[DiscoveredDependency],
|
||||
dependencies: list[dict[str, Any]],
|
||||
report_items: list[ResourceReportItem],
|
||||
) -> None:
|
||||
existing = {(item.get("kind"), item.get("provider_id")) for item in dependencies}
|
||||
for dependency in dependencies_to_record:
|
||||
key = (dependency.kind.value, dependency.provider_id)
|
||||
if key in existing:
|
||||
continue
|
||||
existing.add(key)
|
||||
dependencies.append(
|
||||
{
|
||||
"kind": dependency.kind.value,
|
||||
"provider_id": dependency.provider_id,
|
||||
"provider_name": dependency.provider_name,
|
||||
"source": dependency.source,
|
||||
}
|
||||
)
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
dependency.provider_id,
|
||||
self._dependency_report_name(dependency),
|
||||
"dependency-only",
|
||||
self._dependency_message(dependency.kind),
|
||||
)
|
||||
)
|
||||
|
||||
def _provider_ids(
|
||||
self,
|
||||
manual_provider_ids: Iterable[str],
|
||||
discovered_dependencies: Iterable[DiscoveredDependency],
|
||||
kind: DependencyKind,
|
||||
) -> list[str]:
|
||||
provider_ids = list(manual_provider_ids)
|
||||
provider_ids.extend(
|
||||
self._provider_export_identifier(dependency)
|
||||
for dependency in discovered_dependencies
|
||||
if dependency.kind == kind
|
||||
)
|
||||
return self._dedupe(provider_ids)
|
||||
|
||||
def _provider_export_identifier(self, dependency: DiscoveredDependency) -> str:
|
||||
if dependency.kind == DependencyKind.API_TOOL and dependency.provider_name:
|
||||
return dependency.provider_name
|
||||
return dependency.provider_id
|
||||
|
||||
def _dependencies_by_kind(
|
||||
self, discovered_dependencies: Iterable[DiscoveredDependency], kind: DependencyKind
|
||||
) -> list[DiscoveredDependency]:
|
||||
return [dependency for dependency in discovered_dependencies if dependency.kind == kind]
|
||||
|
||||
def _dedupe(self, values: Iterable[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
def _dependency_message(self, kind: DependencyKind) -> str:
|
||||
if kind == DependencyKind.MCP_TOOL:
|
||||
return "Configure MCP provider manually in the target tenant unless exporting with secrets enabled."
|
||||
if kind == DependencyKind.BUILTIN_OR_PLUGIN_TOOL:
|
||||
return "Ensure the built-in or plugin tool exists in the target environment."
|
||||
return "Dependency metadata only; ensure the resource exists in the target environment."
|
||||
|
||||
def _dependency_report_name(self, dependency: DiscoveredDependency) -> str:
|
||||
name = dependency.provider_name or dependency.provider_id
|
||||
if dependency.kind == DependencyKind.WORKFLOW_TOOL:
|
||||
return f"workflow {name}"
|
||||
return f"{dependency.kind.value} {name}"
|
||||
@ -1,938 +0,0 @@
|
||||
"""Apply versioned migration packages to an explicitly resolved target tenant.
|
||||
|
||||
Import target resolution is deliberately performed before any resource import
|
||||
work. The service does not write Click output; callers receive structured
|
||||
report items and can decide how to render them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
import yaml
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.entities.mcp_provider import MCPAuthentication, MCPConfiguration
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account, ApiToken, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import App
|
||||
from models.tools import ApiToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
DependencyKind,
|
||||
IdStrategy,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
ImportTarget,
|
||||
MigrationDataError,
|
||||
MigrationPackage,
|
||||
ReportContext,
|
||||
ResourceIdMapping,
|
||||
ResourceReportItem,
|
||||
ResourceType,
|
||||
)
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.tools.api_tools_manage_service import ApiToolManageService
|
||||
from services.tools.mcp_tools_manage_service import MCPToolManageService
|
||||
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportRequest:
|
||||
"""Structured input for package import.
|
||||
|
||||
`cli_target_tenant` and `config_target_tenant` are target tenant names from
|
||||
outer adapters. They intentionally override package metadata, because a
|
||||
migration package may be reused across environments.
|
||||
"""
|
||||
|
||||
package: MigrationPackage
|
||||
cli_target_tenant: str | None = None
|
||||
config_target_tenant: str | None = None
|
||||
operator_email: str | None = None
|
||||
options_override: ImportOptions | None = None
|
||||
|
||||
|
||||
class ImportTargetResolver:
|
||||
"""Resolve the target tenant and operator before import side effects begin."""
|
||||
|
||||
def select_target_tenant_name(self, request: ImportRequest) -> str:
|
||||
if request.cli_target_tenant:
|
||||
return request.cli_target_tenant
|
||||
if request.config_target_tenant:
|
||||
return request.config_target_tenant
|
||||
package_target = request.package.metadata.target_tenant or {}
|
||||
if package_target.get("name"):
|
||||
return package_target["name"]
|
||||
if package_target.get("id"):
|
||||
return package_target["id"]
|
||||
raise MigrationDataError(
|
||||
"Target tenant must be provided by --target-tenant, import config, or package metadata."
|
||||
)
|
||||
|
||||
def resolve(self, request: ImportRequest) -> ImportTarget:
|
||||
target_tenant_name = self.select_target_tenant_name(request)
|
||||
package_target = request.package.metadata.target_tenant or {}
|
||||
if request.cli_target_tenant or request.config_target_tenant:
|
||||
tenant = self._resolve_tenant_by_id_or_name(target_tenant_name)
|
||||
elif package_target.get("id") and self._is_uuid(package_target["id"]):
|
||||
tenant = db.session.get(Tenant, package_target["id"])
|
||||
if tenant is not None and package_target.get("name") and tenant.name != package_target.get("name"):
|
||||
raise MigrationDataError(
|
||||
f"Target tenant id/name mismatch: {package_target['id']} / {package_target['name']}"
|
||||
)
|
||||
else:
|
||||
tenant = self._resolve_tenant_by_id_or_name(target_tenant_name)
|
||||
if tenant is None:
|
||||
raise MigrationDataError(f"Target tenant not found: {target_tenant_name}")
|
||||
|
||||
account_query = (
|
||||
db.session.query(Account)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.filter(TenantAccountJoin.tenant_id == tenant.id)
|
||||
)
|
||||
if request.operator_email:
|
||||
account_query = account_query.filter(Account.email == request.operator_email)
|
||||
identity = request.operator_email
|
||||
else:
|
||||
account_query = account_query.filter(TenantAccountJoin.role == TenantAccountRole.OWNER).order_by(
|
||||
TenantAccountJoin.created_at.asc()
|
||||
)
|
||||
identity = "earliest owner"
|
||||
|
||||
account = account_query.first()
|
||||
if account is None:
|
||||
raise MigrationDataError(f"No operator account found for target tenant {target_tenant_name}: {identity}")
|
||||
|
||||
return ImportTarget(
|
||||
tenant_id=tenant.id,
|
||||
tenant_name=tenant.name,
|
||||
operator_id=account.id,
|
||||
operator_email=account.email,
|
||||
)
|
||||
|
||||
def _resolve_tenant_by_id_or_name(self, value: str) -> Tenant | None:
|
||||
if self._is_uuid(value):
|
||||
tenant = db.session.get(Tenant, value)
|
||||
if tenant is not None:
|
||||
return tenant
|
||||
tenants = list(db.session.scalars(sa.select(Tenant).where(Tenant.name == value)).all())
|
||||
if len(tenants) > 1:
|
||||
raise MigrationDataError(f"Target tenant name is ambiguous; use target_tenant.id: {value}")
|
||||
return tenants[0] if tenants else None
|
||||
|
||||
def _is_uuid(self, value: str) -> bool:
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class MigrationImportService:
|
||||
"""Apply package resources using Dify service APIs and structured reporting."""
|
||||
|
||||
target_resolver: ImportTargetResolver
|
||||
|
||||
def __init__(self, *, target_resolver: ImportTargetResolver | None = None) -> None:
|
||||
self.target_resolver = target_resolver or ImportTargetResolver()
|
||||
|
||||
def import_package(self, request: ImportRequest) -> ImportResult:
|
||||
target = self.target_resolver.resolve(request)
|
||||
options = request.options_override or request.package.metadata.import_options
|
||||
report_items = [
|
||||
ResourceReportItem(
|
||||
resource_type=ResourceType.DEPENDENCY,
|
||||
identifier=target.tenant_id,
|
||||
name=target.tenant_name,
|
||||
status="resolved",
|
||||
message=f"operator: {target.operator_email or target.operator_id}",
|
||||
)
|
||||
]
|
||||
id_mapping: dict[str, str] = {}
|
||||
id_mapping_details: list[ResourceIdMapping] = []
|
||||
|
||||
self._import_api_tools(
|
||||
request.package,
|
||||
target,
|
||||
options,
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
self._source_api_provider_ids_by_name(request.package),
|
||||
)
|
||||
self._import_mcp_tools(request.package, target, options, report_items, id_mapping, id_mapping_details)
|
||||
self._preflight_dependency_only_mcp(request.package, target, report_items)
|
||||
workflow_tool_app_ids = self._workflow_tool_source_app_ids(request.package)
|
||||
imported_workflow_ids: set[str] = set()
|
||||
if workflow_tool_app_ids:
|
||||
self._import_workflows(
|
||||
request.package,
|
||||
target,
|
||||
options,
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details=id_mapping_details,
|
||||
imported_workflow_ids=imported_workflow_ids,
|
||||
only_app_ids=workflow_tool_app_ids,
|
||||
)
|
||||
self._import_workflow_tools(request.package, target, options, id_mapping, id_mapping_details, report_items)
|
||||
self._import_workflows(
|
||||
request.package,
|
||||
target,
|
||||
options,
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details=id_mapping_details,
|
||||
imported_workflow_ids=imported_workflow_ids,
|
||||
skip_app_ids=imported_workflow_ids,
|
||||
)
|
||||
return ImportResult(
|
||||
report_items=report_items,
|
||||
id_mapping=id_mapping,
|
||||
report_context=ReportContext(
|
||||
target_tenant=target.tenant_name,
|
||||
operator_email=target.operator_email,
|
||||
id_mapping_count=len(id_mapping),
|
||||
id_mappings=dict(id_mapping),
|
||||
id_mapping_details=id_mapping_details,
|
||||
),
|
||||
)
|
||||
|
||||
def _import_workflows(
|
||||
self,
|
||||
package: MigrationPackage,
|
||||
target: ImportTarget,
|
||||
options: ImportOptions,
|
||||
report_items: list[ResourceReportItem],
|
||||
id_mapping: dict[str, str],
|
||||
id_mapping_details: list[ResourceIdMapping],
|
||||
imported_workflow_ids: set[str] | None = None,
|
||||
only_app_ids: set[str] | None = None,
|
||||
skip_app_ids: set[str] | None = None,
|
||||
) -> None:
|
||||
account = db.session.get(Account, target.operator_id)
|
||||
tenant = db.session.get(Tenant, target.tenant_id)
|
||||
if account is None:
|
||||
raise MigrationDataError(f"Operator account not found: {target.operator_id}")
|
||||
if tenant is None:
|
||||
raise MigrationDataError(f"Target tenant not found: {target.tenant_id}")
|
||||
account.current_tenant = tenant
|
||||
|
||||
for workflow_data in package.workflows:
|
||||
app_id = self._optional_string(workflow_data.get("id"))
|
||||
if only_app_ids and app_id not in only_app_ids:
|
||||
continue
|
||||
if skip_app_ids and app_id in skip_app_ids:
|
||||
continue
|
||||
dsl_content = self._rewrite_workflow_dsl_provider_ids(
|
||||
self._required_string(workflow_data, "dsl", "workflow"),
|
||||
id_mapping,
|
||||
)
|
||||
existing_app = (
|
||||
self._find_existing_app(app_id, target.tenant_id)
|
||||
if options.id_strategy == IdStrategy.PRESERVE_ID
|
||||
else None
|
||||
)
|
||||
if existing_app is not None and options.conflict_strategy == ConflictStrategy.FAIL:
|
||||
raise MigrationDataError(f"App already exists and conflict_strategy=fail: {app_id}")
|
||||
if existing_app is not None and options.conflict_strategy == ConflictStrategy.SKIP:
|
||||
if app_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.WORKFLOW,
|
||||
workflow_data.get("name") if isinstance(workflow_data.get("name"), str) else None,
|
||||
{app_id},
|
||||
existing_app.id,
|
||||
)
|
||||
report_items.append(
|
||||
ResourceReportItem(ResourceType.WORKFLOW, str(app_id), workflow_data.get("name"), "skipped")
|
||||
)
|
||||
continue
|
||||
|
||||
imported_app_id = self._import_workflow_app(
|
||||
account=account,
|
||||
workflow_data=workflow_data,
|
||||
dsl_content=dsl_content,
|
||||
app_id=app_id,
|
||||
existing_app=existing_app,
|
||||
options=options,
|
||||
)
|
||||
if app_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.WORKFLOW,
|
||||
workflow_data.get("name") if isinstance(workflow_data.get("name"), str) else None,
|
||||
{app_id},
|
||||
imported_app_id,
|
||||
)
|
||||
if imported_workflow_ids is not None:
|
||||
imported_workflow_ids.add(app_id)
|
||||
if options.create_app_api_token_on_import:
|
||||
self._create_or_reuse_app_api_token(imported_app_id, target.tenant_id)
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.WORKFLOW,
|
||||
imported_app_id,
|
||||
workflow_data.get("name"),
|
||||
"updated" if existing_app is not None else "created",
|
||||
)
|
||||
)
|
||||
|
||||
def _workflow_tool_source_app_ids(self, package: MigrationPackage) -> set[str]:
|
||||
app_ids: set[str] = set()
|
||||
for workflow_tool_data in package.workflow_tools:
|
||||
app_id = self._optional_string(workflow_tool_data.get("app_id"))
|
||||
if app_id:
|
||||
app_ids.add(app_id)
|
||||
return app_ids
|
||||
|
||||
def _import_workflow_app(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
workflow_data: dict[str, object],
|
||||
dsl_content: str,
|
||||
app_id: str | None,
|
||||
existing_app: App | None,
|
||||
options: ImportOptions,
|
||||
) -> str:
|
||||
import_service = AppDslService(cast(Session, db.session))
|
||||
if existing_app is not None:
|
||||
import_result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode="yaml-content",
|
||||
yaml_content=dsl_content,
|
||||
app_id=existing_app.id,
|
||||
)
|
||||
else:
|
||||
import_app_id = app_id if self._should_preserve_source_app_id(options) else None
|
||||
import_result = import_service.import_app(
|
||||
account=account,
|
||||
import_mode="yaml-content",
|
||||
yaml_content=dsl_content,
|
||||
import_app_id=import_app_id,
|
||||
)
|
||||
if import_result.status not in {ImportStatus.COMPLETED, ImportStatus.COMPLETED_WITH_WARNINGS}:
|
||||
error = import_result.error or f"unexpected import status {import_result.status}"
|
||||
raise MigrationDataError(f"Workflow import failed: {error}")
|
||||
if import_result.app_id is None:
|
||||
raise MigrationDataError(f"Workflow import did not return an app id: {workflow_data.get('name')}")
|
||||
db.session.commit()
|
||||
return import_result.app_id
|
||||
|
||||
def _rewrite_workflow_dsl_provider_ids(self, dsl_content: str, id_mapping: dict[str, str]) -> str:
|
||||
if not id_mapping:
|
||||
return dsl_content
|
||||
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
|
||||
if not isinstance(parsed, dict):
|
||||
return dsl_content
|
||||
for node in self._workflow_nodes(parsed):
|
||||
data = node.get("data") if isinstance(node, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
self._rewrite_tool_config_provider_id(data, id_mapping)
|
||||
for tool_config in self._agent_tool_configs(data):
|
||||
self._rewrite_tool_config_provider_id(tool_config, id_mapping)
|
||||
return yaml.safe_dump(parsed, sort_keys=False, allow_unicode=True)
|
||||
|
||||
def _rewrite_tool_config_provider_id(self, tool_config: dict[str, Any], id_mapping: dict[str, str]) -> None:
|
||||
provider_id = self._optional_string(tool_config.get("provider_id"))
|
||||
if provider_id and provider_id in id_mapping:
|
||||
tool_config["provider_id"] = id_mapping[provider_id]
|
||||
|
||||
def _source_api_provider_ids_by_name(self, package: MigrationPackage) -> dict[str, set[str]]:
|
||||
provider_ids_by_name: dict[str, set[str]] = {}
|
||||
discovery_service = DependencyDiscoveryService()
|
||||
for workflow_data in package.workflows:
|
||||
dsl_content = self._optional_string(workflow_data.get("dsl"))
|
||||
if not dsl_content:
|
||||
continue
|
||||
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
|
||||
if not isinstance(parsed, dict):
|
||||
continue
|
||||
for dependency in discovery_service.discover_from_dsl(parsed):
|
||||
if dependency.kind != DependencyKind.API_TOOL or not dependency.provider_name:
|
||||
continue
|
||||
provider_ids_by_name.setdefault(dependency.provider_name, set()).add(dependency.provider_id)
|
||||
return provider_ids_by_name
|
||||
|
||||
def _workflow_nodes(self, dsl: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
nodes: list[dict[str, Any]] = []
|
||||
graph = dsl.get("graph")
|
||||
if isinstance(graph, dict) and isinstance(graph.get("nodes"), list):
|
||||
nodes.extend(node for node in graph["nodes"] if isinstance(node, dict))
|
||||
workflow = dsl.get("workflow")
|
||||
workflow_graph = workflow.get("graph") if isinstance(workflow, dict) else None
|
||||
if isinstance(workflow_graph, dict) and isinstance(workflow_graph.get("nodes"), list):
|
||||
nodes.extend(node for node in workflow_graph["nodes"] if isinstance(node, dict))
|
||||
return nodes
|
||||
|
||||
def _agent_tool_configs(self, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
configs = data.get("tools")
|
||||
if isinstance(configs, list):
|
||||
return [config for config in configs if isinstance(config, dict)]
|
||||
agent_parameters = data.get("agent_parameters")
|
||||
if not isinstance(agent_parameters, dict):
|
||||
return []
|
||||
tools_parameter = agent_parameters.get("tools")
|
||||
if not isinstance(tools_parameter, dict):
|
||||
return []
|
||||
value = tools_parameter.get("value", [])
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [config for config in value if isinstance(config, dict)]
|
||||
|
||||
def _should_preserve_source_app_id(self, options: ImportOptions) -> bool:
|
||||
return options.id_strategy == IdStrategy.PRESERVE_ID
|
||||
|
||||
def _find_existing_app(self, app_id: str | None, tenant_id: str) -> App | None:
|
||||
if not self._is_uuid_string(app_id):
|
||||
return None
|
||||
return db.session.scalar(sa.select(App).where(App.id == app_id, App.tenant_id == tenant_id))
|
||||
|
||||
def _create_or_reuse_app_api_token(self, app_id: str, tenant_id: str) -> None:
|
||||
existing = db.session.scalar(
|
||||
sa.select(ApiToken).where(
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_id,
|
||||
ApiToken.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return
|
||||
api_token = ApiToken()
|
||||
api_token.app_id = app_id
|
||||
api_token.tenant_id = tenant_id
|
||||
api_token.token = ApiToken.generate_api_key("app", 24)
|
||||
api_token.type = ApiTokenType.APP
|
||||
db.session.add(api_token)
|
||||
db.session.commit()
|
||||
|
||||
def _import_api_tools(
|
||||
self,
|
||||
package: MigrationPackage,
|
||||
target: ImportTarget,
|
||||
options: ImportOptions,
|
||||
report_items: list[ResourceReportItem],
|
||||
id_mapping: dict[str, str],
|
||||
id_mapping_details: list[ResourceIdMapping],
|
||||
source_provider_ids_by_name: dict[str, set[str]],
|
||||
) -> None:
|
||||
for tool_data in package.tools:
|
||||
provider_name = self._required_string(tool_data, "provider_name", "api_tool")
|
||||
schema = self._required_string(tool_data, "schema", "api_tool")
|
||||
existing = db.session.scalar(
|
||||
sa.select(ApiToolProvider).where(
|
||||
ApiToolProvider.tenant_id == target.tenant_id,
|
||||
ApiToolProvider.name == provider_name,
|
||||
)
|
||||
)
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
|
||||
raise MigrationDataError(f"API tool already exists and conflict_strategy=fail: {provider_name}")
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.API_TOOL,
|
||||
provider_name,
|
||||
self._api_tool_source_ids(provider_name, tool_data, source_provider_ids_by_name),
|
||||
existing.id,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_name, provider_name, "skipped"))
|
||||
continue
|
||||
|
||||
schema_info = ApiToolManageService.parser_api_schema(schema=schema)
|
||||
schema_type = cast(ApiProviderSchemaType, schema_info["schema_type"])
|
||||
credentials = (
|
||||
cast(dict[str, Any], tool_data.get("credentials"))
|
||||
if isinstance(tool_data.get("credentials"), dict)
|
||||
else {}
|
||||
)
|
||||
credentials = credentials or {"auth_type": "none"}
|
||||
raw_icon = tool_data.get("icon")
|
||||
icon = (
|
||||
cast(dict[str, Any], raw_icon)
|
||||
if isinstance(raw_icon, dict)
|
||||
else {"content": "tool", "background": "#FEF7C3"}
|
||||
)
|
||||
raw_labels = tool_data.get("labels")
|
||||
labels = [str(label) for label in raw_labels] if isinstance(raw_labels, list) else []
|
||||
if existing is not None:
|
||||
ApiToolManageService.update_api_tool_provider(
|
||||
user_id=target.operator_id,
|
||||
tenant_id=target.tenant_id,
|
||||
provider_name=provider_name,
|
||||
original_provider=existing.name,
|
||||
_schema_type=schema_type,
|
||||
schema=schema,
|
||||
privacy_policy=self._optional_string(tool_data.get("privacy_policy")) or "",
|
||||
credentials=credentials,
|
||||
custom_disclaimer=self._optional_string(tool_data.get("custom_disclaimer")) or "",
|
||||
labels=labels,
|
||||
icon=icon,
|
||||
)
|
||||
status = "updated"
|
||||
else:
|
||||
ApiToolManageService.create_api_tool_provider(
|
||||
user_id=target.operator_id,
|
||||
tenant_id=target.tenant_id,
|
||||
provider_name=provider_name,
|
||||
schema_type=schema_type,
|
||||
schema=schema,
|
||||
privacy_policy=self._optional_string(tool_data.get("privacy_policy")) or "",
|
||||
credentials=credentials,
|
||||
custom_disclaimer=self._optional_string(tool_data.get("custom_disclaimer")) or "",
|
||||
labels=labels,
|
||||
icon=icon,
|
||||
)
|
||||
status = "created"
|
||||
target_provider = self._find_api_tool_provider(target.tenant_id, provider_name)
|
||||
if target_provider is not None:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.API_TOOL,
|
||||
provider_name,
|
||||
self._api_tool_source_ids(provider_name, tool_data, source_provider_ids_by_name),
|
||||
target_provider.id,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.API_TOOL, provider_name, provider_name, status))
|
||||
|
||||
def _find_api_tool_provider(self, tenant_id: str, provider_name: str) -> ApiToolProvider | None:
|
||||
return db.session.scalar(
|
||||
sa.select(ApiToolProvider).where(
|
||||
ApiToolProvider.tenant_id == tenant_id,
|
||||
ApiToolProvider.name == provider_name,
|
||||
)
|
||||
)
|
||||
|
||||
def _api_tool_source_ids(
|
||||
self,
|
||||
provider_name: str,
|
||||
tool_data: dict[str, Any],
|
||||
source_provider_ids_by_name: dict[str, set[str]],
|
||||
) -> set[str]:
|
||||
source_ids = set(source_provider_ids_by_name.get(provider_name, set()))
|
||||
source_id = self._optional_string(tool_data.get("id"))
|
||||
if source_id:
|
||||
source_ids.add(source_id)
|
||||
return source_ids
|
||||
|
||||
def _record_id_mappings(
|
||||
self,
|
||||
id_mapping: dict[str, str],
|
||||
id_mapping_details: list[ResourceIdMapping],
|
||||
resource_type: ResourceType,
|
||||
name: str | None,
|
||||
source_ids: Iterable[str],
|
||||
target_id: str,
|
||||
) -> None:
|
||||
for source_id in source_ids:
|
||||
id_mapping[source_id] = target_id
|
||||
id_mapping_details[:] = [item for item in id_mapping_details if item.source_id != source_id]
|
||||
id_mapping_details.append(ResourceIdMapping(resource_type, name, source_id, target_id))
|
||||
|
||||
def _import_workflow_tools(
|
||||
self,
|
||||
package: MigrationPackage,
|
||||
target: ImportTarget,
|
||||
options: ImportOptions,
|
||||
id_mapping: dict[str, str],
|
||||
id_mapping_details: list[ResourceIdMapping],
|
||||
report_items: list[ResourceReportItem],
|
||||
) -> None:
|
||||
if not package.workflow_tools:
|
||||
return
|
||||
account = db.session.get(Account, target.operator_id)
|
||||
if account is None:
|
||||
raise MigrationDataError(f"Operator account not found: {target.operator_id}")
|
||||
for workflow_tool_data in package.workflow_tools:
|
||||
app_id = self._optional_string(workflow_tool_data.get("app_id"))
|
||||
resolved_app_id = id_mapping.get(app_id or "", app_id)
|
||||
if not resolved_app_id or self._find_existing_app(resolved_app_id, target.tenant_id) is None:
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
str(workflow_tool_data.get("id", workflow_tool_data.get("name", ""))),
|
||||
self._optional_string(workflow_tool_data.get("name")),
|
||||
"unresolved",
|
||||
"Referenced workflow app was not found in the target tenant; workflow tool was skipped.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
try:
|
||||
self._ensure_workflow_app_is_published(target, account, resolved_app_id)
|
||||
except Exception as exc:
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
str(workflow_tool_data.get("id", workflow_tool_data.get("name", ""))),
|
||||
self._optional_string(workflow_tool_data.get("name")),
|
||||
"unresolved",
|
||||
f"Referenced workflow app could not be published: {exc}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
workflow_tool_id = self._optional_string(workflow_tool_data.get("id"))
|
||||
tool_name = self._required_string(workflow_tool_data, "name", "workflow_tool")
|
||||
lookup_workflow_tool_id = workflow_tool_id if options.id_strategy == IdStrategy.PRESERVE_ID else None
|
||||
existing = self._find_existing_workflow_tool(
|
||||
target.tenant_id, lookup_workflow_tool_id, tool_name, resolved_app_id
|
||||
)
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
|
||||
raise MigrationDataError(f"Workflow tool already exists and conflict_strategy=fail: {tool_name}")
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
|
||||
if workflow_tool_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
tool_name,
|
||||
{workflow_tool_id},
|
||||
existing.id,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.WORKFLOW_TOOL, existing.id, tool_name, "skipped"))
|
||||
continue
|
||||
raw_icon = workflow_tool_data.get("icon")
|
||||
icon = (
|
||||
cast(dict[str, Any], raw_icon)
|
||||
if isinstance(raw_icon, dict)
|
||||
else {"content": "🤖", "background": "#FFEAD5"}
|
||||
)
|
||||
raw_parameters = workflow_tool_data.get("parameters")
|
||||
parameters = [
|
||||
parameter
|
||||
if isinstance(parameter, WorkflowToolParameterConfiguration)
|
||||
else WorkflowToolParameterConfiguration(**parameter)
|
||||
for parameter in (raw_parameters if isinstance(raw_parameters, list) else [])
|
||||
if isinstance(parameter, dict | WorkflowToolParameterConfiguration)
|
||||
]
|
||||
raw_labels = workflow_tool_data.get("labels")
|
||||
labels = [str(label) for label in raw_labels] if isinstance(raw_labels, list) else []
|
||||
label = self._optional_string(workflow_tool_data.get("label")) or tool_name
|
||||
description = self._optional_string(workflow_tool_data.get("description")) or ""
|
||||
privacy_policy = self._optional_string(workflow_tool_data.get("privacy_policy")) or ""
|
||||
if existing is not None:
|
||||
WorkflowToolManageService.update_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=target.tenant_id,
|
||||
workflow_tool_id=existing.id,
|
||||
name=tool_name,
|
||||
label=label,
|
||||
icon=icon,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
privacy_policy=privacy_policy,
|
||||
labels=labels,
|
||||
)
|
||||
status = "updated"
|
||||
identifier = existing.id
|
||||
else:
|
||||
import_id = workflow_tool_id if options.id_strategy == IdStrategy.PRESERVE_ID else ""
|
||||
WorkflowToolManageService.create_workflow_tool(
|
||||
user_id=account.id,
|
||||
tenant_id=target.tenant_id,
|
||||
workflow_app_id=resolved_app_id,
|
||||
name=tool_name,
|
||||
label=label,
|
||||
icon=icon,
|
||||
description=description,
|
||||
parameters=parameters,
|
||||
privacy_policy=privacy_policy,
|
||||
labels=labels,
|
||||
import_id=import_id or "",
|
||||
)
|
||||
status = "created"
|
||||
target_provider = self._find_existing_workflow_tool(
|
||||
target.tenant_id, import_id or None, tool_name, resolved_app_id
|
||||
)
|
||||
if target_provider is None:
|
||||
raise MigrationDataError(f"Workflow tool was not created: {tool_name}")
|
||||
identifier = target_provider.id
|
||||
if workflow_tool_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
tool_name,
|
||||
{workflow_tool_id},
|
||||
identifier,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.WORKFLOW_TOOL, identifier, tool_name, status))
|
||||
|
||||
def _ensure_workflow_app_is_published(self, target: ImportTarget, account: Account, app_id: str) -> None:
|
||||
app = self._find_existing_app(app_id, target.tenant_id)
|
||||
if app is None:
|
||||
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
|
||||
if app.workflow_id:
|
||||
return
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
app_in_session = session.get(App, app_id)
|
||||
account_in_session = session.get(Account, account.id)
|
||||
if app_in_session is None:
|
||||
raise MigrationDataError(f"Referenced workflow app was not found in target tenant: {app_id}")
|
||||
if account_in_session is None:
|
||||
raise MigrationDataError(f"Operator account not found: {account.id}")
|
||||
workflow = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_in_session,
|
||||
account=account_in_session,
|
||||
marked_name="Migration import",
|
||||
marked_comment="Published automatically for workflow tool import.",
|
||||
)
|
||||
app_in_session.workflow_id = workflow.id
|
||||
app_in_session.updated_by = account.id
|
||||
app_in_session.updated_at = naive_utc_now()
|
||||
|
||||
def _import_mcp_tools(
|
||||
self,
|
||||
package: MigrationPackage,
|
||||
target: ImportTarget,
|
||||
options: ImportOptions,
|
||||
report_items: list[ResourceReportItem],
|
||||
id_mapping: dict[str, str],
|
||||
id_mapping_details: list[ResourceIdMapping],
|
||||
) -> None:
|
||||
for mcp_data in package.mcp_tools:
|
||||
name = self._required_string(mcp_data, "name", "mcp_tool")
|
||||
server_identifier = self._required_string(mcp_data, "server_identifier", "mcp_tool")
|
||||
provider_id = self._optional_string(mcp_data.get("id"))
|
||||
lookup_provider_id = provider_id if options.id_strategy == IdStrategy.PRESERVE_ID else None
|
||||
existing = self._find_existing_mcp_tool(target.tenant_id, lookup_provider_id, server_identifier)
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.FAIL:
|
||||
raise MigrationDataError(f"MCP tool already exists and conflict_strategy=fail: {name}")
|
||||
if existing is not None and options.conflict_strategy == ConflictStrategy.SKIP:
|
||||
if provider_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.MCP_TOOL,
|
||||
name,
|
||||
{provider_id},
|
||||
existing.id,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, existing.id, name, "skipped"))
|
||||
continue
|
||||
|
||||
service = MCPToolManageService(session=cast(Session, db.session))
|
||||
configuration = MCPConfiguration.model_validate(mcp_data.get("configuration") or {})
|
||||
authentication = (
|
||||
MCPAuthentication.model_validate(mcp_data["authentication"]) if mcp_data.get("authentication") else None
|
||||
)
|
||||
if existing is not None:
|
||||
service.update_provider(
|
||||
tenant_id=target.tenant_id,
|
||||
provider_id=existing.id,
|
||||
server_url=self._required_string(mcp_data, "server_url", "mcp_tool"),
|
||||
name=name,
|
||||
icon=self._optional_string(mcp_data.get("icon")) or "",
|
||||
icon_type=self._optional_string(mcp_data.get("icon_type")) or "emoji",
|
||||
icon_background=self._optional_string(mcp_data.get("icon_background")) or "",
|
||||
server_identifier=server_identifier,
|
||||
headers=mcp_data.get("headers") if isinstance(mcp_data.get("headers"), dict) else {},
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
)
|
||||
db.session.commit()
|
||||
status = "updated"
|
||||
identifier = existing.id
|
||||
provider = existing
|
||||
else:
|
||||
service.create_provider(
|
||||
tenant_id=target.tenant_id,
|
||||
user_id=target.operator_id,
|
||||
server_url=self._required_string(mcp_data, "server_url", "mcp_tool"),
|
||||
name=name,
|
||||
icon=self._optional_string(mcp_data.get("icon")) or "",
|
||||
icon_type=self._optional_string(mcp_data.get("icon_type")) or "emoji",
|
||||
icon_background=self._optional_string(mcp_data.get("icon_background")) or "",
|
||||
server_identifier=server_identifier,
|
||||
headers=mcp_data.get("headers") if isinstance(mcp_data.get("headers"), dict) else {},
|
||||
configuration=configuration,
|
||||
authentication=authentication,
|
||||
)
|
||||
created_provider = self._find_existing_mcp_tool(target.tenant_id, lookup_provider_id, server_identifier)
|
||||
if created_provider is None:
|
||||
raise MigrationDataError(f"MCP provider was not created: {name}")
|
||||
status = "created"
|
||||
provider = created_provider
|
||||
identifier = provider.id
|
||||
self._restore_mcp_provider_tools(provider, mcp_data)
|
||||
db.session.commit()
|
||||
if provider_id:
|
||||
self._record_id_mappings(
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
ResourceType.MCP_TOOL,
|
||||
name,
|
||||
{provider_id},
|
||||
identifier,
|
||||
)
|
||||
report_items.append(ResourceReportItem(ResourceType.MCP_TOOL, identifier, name, status))
|
||||
|
||||
def _restore_mcp_provider_tools(self, provider: MCPToolProvider, mcp_data: dict[str, object]) -> None:
|
||||
tools = mcp_data.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
return
|
||||
provider.tools = json.dumps(tools)
|
||||
provider.authed = True
|
||||
|
||||
def _find_existing_mcp_tool(
|
||||
self, tenant_id: str, provider_id: str | None, server_identifier: str
|
||||
) -> MCPToolProvider | None:
|
||||
predicates = [MCPToolProvider.server_identifier == server_identifier]
|
||||
if self._is_uuid_string(provider_id):
|
||||
predicates.append(MCPToolProvider.id == provider_id)
|
||||
return db.session.scalar(
|
||||
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, or_(*predicates)).limit(1)
|
||||
)
|
||||
|
||||
def _is_uuid_string(self, value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
try:
|
||||
UUID(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _find_existing_workflow_tool(
|
||||
self, tenant_id: str, workflow_tool_id: str | None, tool_name: str, app_id: str
|
||||
) -> WorkflowToolProvider | None:
|
||||
predicates = [WorkflowToolProvider.name == tool_name, WorkflowToolProvider.app_id == app_id]
|
||||
if self._is_uuid_string(workflow_tool_id):
|
||||
predicates.append(WorkflowToolProvider.id == workflow_tool_id)
|
||||
return db.session.scalar(
|
||||
sa.select(WorkflowToolProvider)
|
||||
.where(WorkflowToolProvider.tenant_id == tenant_id, or_(*predicates))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
def _preflight_dependency_only_mcp(
|
||||
self, package: MigrationPackage, target: ImportTarget, report_items: list[ResourceReportItem]
|
||||
) -> None:
|
||||
for dependency in package.dependencies:
|
||||
if dependency.get("kind") != DependencyKind.MCP_TOOL.value:
|
||||
continue
|
||||
provider_id = str(dependency.get("provider_id", dependency.get("id", "")))
|
||||
provider_name = self._optional_string(dependency.get("provider_name") or dependency.get("name"))
|
||||
existing = self._find_dependency_only_mcp_provider(target.tenant_id, provider_id, provider_name)
|
||||
report_name = f"mcp_tool {provider_name or getattr(existing, 'name', None) or provider_id}"
|
||||
if existing is not None:
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
provider_id,
|
||||
report_name,
|
||||
"available",
|
||||
"MCP provider exists in target tenant.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
reference_summary = self._dependency_only_mcp_reference_summary(package, provider_id, provider_name)
|
||||
message = "missing in target tenant"
|
||||
if reference_summary:
|
||||
message = f"{message}; referenced by {reference_summary}"
|
||||
message = f"{message}; configure it manually before running the workflow."
|
||||
report_items.append(
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
provider_id,
|
||||
report_name,
|
||||
"skipped",
|
||||
message,
|
||||
)
|
||||
)
|
||||
|
||||
def _find_dependency_only_mcp_provider(
|
||||
self, tenant_id: str, provider_id: str, provider_name: str | None
|
||||
) -> MCPToolProvider | None:
|
||||
predicates = [MCPToolProvider.server_identifier == provider_id]
|
||||
if self._is_uuid_string(provider_id):
|
||||
predicates.append(MCPToolProvider.id == provider_id)
|
||||
return db.session.scalar(
|
||||
sa.select(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant_id, or_(*predicates)).limit(1)
|
||||
)
|
||||
|
||||
def _dependency_only_mcp_reference_summary(
|
||||
self, package: MigrationPackage, provider_id: str, provider_name: str | None
|
||||
) -> str:
|
||||
references = self._dependency_only_mcp_references(package, provider_id, provider_name)
|
||||
return "; ".join(references)
|
||||
|
||||
def _dependency_only_mcp_references(
|
||||
self, package: MigrationPackage, provider_id: str, provider_name: str | None
|
||||
) -> list[str]:
|
||||
references: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for workflow_data in package.workflows:
|
||||
workflow_name = self._optional_string(workflow_data.get("name"))
|
||||
workflow_id = self._optional_string(workflow_data.get("id"))
|
||||
workflow_label = workflow_name or workflow_id or "unknown workflow"
|
||||
dsl_content = self._optional_string(workflow_data.get("dsl"))
|
||||
if not dsl_content:
|
||||
continue
|
||||
parsed = yaml.safe_load(dsl_content) if dsl_content else {}
|
||||
if not isinstance(parsed, dict):
|
||||
continue
|
||||
for node in self._workflow_nodes(parsed):
|
||||
data = node.get("data") if isinstance(node, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
for tool_config in [data, *self._agent_tool_configs(data)]:
|
||||
if not self._is_mcp_dependency_reference(tool_config, provider_id, provider_name):
|
||||
continue
|
||||
tool_label = self._mcp_tool_reference_label(node, tool_config)
|
||||
reference = f"{workflow_label} / {tool_label}"
|
||||
if reference not in seen:
|
||||
seen.add(reference)
|
||||
references.append(reference)
|
||||
return references
|
||||
|
||||
def _is_mcp_dependency_reference(
|
||||
self, tool_config: dict[str, Any], provider_id: str, provider_name: str | None
|
||||
) -> bool:
|
||||
provider_type = str(tool_config.get("provider_type") or tool_config.get("type") or "").lower()
|
||||
if provider_type != "mcp":
|
||||
return False
|
||||
config_provider_id = self._optional_string(
|
||||
tool_config.get("provider_id") or tool_config.get("provider_name") or tool_config.get("provider")
|
||||
)
|
||||
if config_provider_id == provider_id:
|
||||
return True
|
||||
return bool(provider_name and config_provider_id == provider_name)
|
||||
|
||||
def _mcp_tool_reference_label(self, node: dict[str, Any], tool_config: dict[str, Any]) -> str:
|
||||
for key in ("tool_name", "tool", "name"):
|
||||
value = self._optional_string(tool_config.get(key))
|
||||
if value:
|
||||
return value
|
||||
node_id = self._optional_string(node.get("id"))
|
||||
return node_id or "unknown tool"
|
||||
|
||||
def _required_string(self, value: dict[str, object], field_name: str, resource_name: str) -> str:
|
||||
field_value = value.get(field_name)
|
||||
if not isinstance(field_value, str) or not field_value:
|
||||
raise MigrationDataError(f"Missing required {resource_name} field: {field_name}")
|
||||
return field_value
|
||||
|
||||
def _optional_string(self, value: object) -> str | None:
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
@ -1,71 +0,0 @@
|
||||
"""JSON persistence for versioned cross-environment migration packages.
|
||||
|
||||
The package service validates file shape and serializes only structured package
|
||||
entities. It does not perform CLI rendering or database access, keeping it safe
|
||||
to reuse from Click adapters, tests, and future import/export services.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from services.data_migration.entities import (
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
MigrationMetadata,
|
||||
MigrationPackage,
|
||||
SourceTenant,
|
||||
TargetTenantSelector,
|
||||
)
|
||||
|
||||
PACKAGE_VERSION = "1"
|
||||
|
||||
|
||||
class MigrationPackageService:
|
||||
def load_package(self, path: str | Path) -> MigrationPackage:
|
||||
package_path = Path(path)
|
||||
with package_path.open(encoding="utf-8") as file:
|
||||
raw = json.load(file)
|
||||
if not isinstance(raw, dict):
|
||||
raise MigrationDataError("Migration package JSON must be an object.")
|
||||
package = MigrationPackage.from_mapping(raw)
|
||||
if package.metadata.version != PACKAGE_VERSION:
|
||||
raise MigrationDataError(f"Unsupported migration package version: {package.metadata.version}")
|
||||
return package
|
||||
|
||||
def save_package(self, package: MigrationPackage, path: str | Path, *, overwrite: bool) -> None:
|
||||
package_path = Path(path)
|
||||
if package_path.exists() and not overwrite:
|
||||
raise MigrationDataError(f"Output file already exists: {package_path}")
|
||||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with package_path.open("w", encoding="utf-8") as file:
|
||||
json.dump(self.to_mapping(package), file, ensure_ascii=False, indent=2)
|
||||
file.write("\n")
|
||||
|
||||
def build_empty_package(
|
||||
self,
|
||||
*,
|
||||
source_tenant_id: str,
|
||||
source_tenant_name: str,
|
||||
include_secrets: bool,
|
||||
import_options: ImportOptions | None = None,
|
||||
target_tenant: TargetTenantSelector | None = None,
|
||||
) -> MigrationPackage:
|
||||
return MigrationPackage(
|
||||
metadata=MigrationMetadata(
|
||||
version=PACKAGE_VERSION,
|
||||
source_scope="single",
|
||||
source_tenants=[SourceTenant(id=source_tenant_id, name=source_tenant_name)],
|
||||
target_tenant=target_tenant,
|
||||
created_at=datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
include_secrets=include_secrets,
|
||||
import_options=import_options or ImportOptions(),
|
||||
)
|
||||
)
|
||||
|
||||
def to_mapping(self, package: MigrationPackage) -> dict[str, Any]:
|
||||
return asdict(package)
|
||||
@ -1,76 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from services.data_migration.entities import ReportContext, ResourceIdMapping, ResourceReportItem
|
||||
|
||||
|
||||
class MigrationReportService:
|
||||
"""Render structured migration resource results into CLI-friendly summary lines."""
|
||||
|
||||
def render(self, items: list[ResourceReportItem], *, context: ReportContext | None = None) -> list[str]:
|
||||
counts = Counter((item.resource_type.value, item.status) for item in items)
|
||||
lines = self._render_context(context)
|
||||
lines.extend(
|
||||
[f"{resource_type} {status}: {count}" for (resource_type, status), count in sorted(counts.items())]
|
||||
)
|
||||
actionable_items = [
|
||||
item for item in items if item.status in {"dependency-only", "skipped", "unresolved"} and item.message
|
||||
]
|
||||
for item in actionable_items:
|
||||
lines.append(self._render_actionable_detail(item))
|
||||
return lines
|
||||
|
||||
def _render_context(self, context: ReportContext | None) -> list[str]:
|
||||
if context is None:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
if context.output_path:
|
||||
lines.append(f"output: {context.output_path}")
|
||||
if context.source_scope:
|
||||
lines.append(f"source scope: {context.source_scope}")
|
||||
if context.selected_app_count is not None:
|
||||
lines.append(f"selected apps: {context.selected_app_count}")
|
||||
if context.include_secrets is not None:
|
||||
lines.append(f"include secrets: {str(context.include_secrets).lower()}")
|
||||
if context.target_tenant:
|
||||
lines.append(f"target tenant: {context.target_tenant}")
|
||||
if context.operator_email:
|
||||
lines.append(f"operator: {context.operator_email}")
|
||||
if context.app_api_tokens_created or context.app_api_tokens_reused:
|
||||
lines.append(
|
||||
f"app api tokens: {context.app_api_tokens_created} created, {context.app_api_tokens_reused} reused"
|
||||
)
|
||||
if context.id_mappings:
|
||||
lines.append(f"resource references resolved: {len(context.id_mappings)}")
|
||||
if context.id_mapping_details:
|
||||
lines.extend(
|
||||
self._render_id_mapping_detail(item)
|
||||
for item in sorted(
|
||||
context.id_mapping_details,
|
||||
key=lambda item: (item.resource_type.value, item.name or "", item.source_id),
|
||||
)
|
||||
)
|
||||
else:
|
||||
lines.extend(
|
||||
f"- {source_id} -> {target_id}" for source_id, target_id in sorted(context.id_mappings.items())
|
||||
)
|
||||
elif context.id_mapping_count:
|
||||
lines.append(f"resource references resolved: {context.id_mapping_count}")
|
||||
return lines
|
||||
|
||||
def _render_id_mapping_detail(self, item: ResourceIdMapping) -> str:
|
||||
label = item.resource_type.value
|
||||
if item.name:
|
||||
label = f"{label} {item.name}"
|
||||
return f"- {label}: {item.source_id} -> {item.target_id}"
|
||||
|
||||
def _render_actionable_detail(self, item: ResourceReportItem) -> str:
|
||||
if item.resource_type.value == "dependency" and item.name and self._has_dependency_type_prefix(item.name):
|
||||
if item.identifier and item.identifier not in item.name:
|
||||
return f"dependency {item.name}: {item.identifier}: {item.message}"
|
||||
return f"dependency {item.name}: {item.message}"
|
||||
return f"{item.resource_type.value} {item.identifier}: {item.message}"
|
||||
|
||||
def _has_dependency_type_prefix(self, name: str) -> bool:
|
||||
return name.startswith(("workflow ", "api_tool ", "workflow_tool ", "mcp_tool ", "builtin_or_plugin_tool "))
|
||||
@ -41,7 +41,6 @@ class WorkflowToolManageService:
|
||||
parameters: list[WorkflowToolParameterConfiguration],
|
||||
privacy_policy: str = "",
|
||||
labels: list[str] | None = None,
|
||||
import_id: str = "",
|
||||
):
|
||||
# check if the name is unique
|
||||
existing_workflow_tool_provider: WorkflowToolProvider | None = None
|
||||
@ -93,8 +92,7 @@ class WorkflowToolManageService:
|
||||
privacy_policy=privacy_policy,
|
||||
version=workflow.version,
|
||||
)
|
||||
if import_id:
|
||||
workflow_tool_provider.id = import_id
|
||||
|
||||
try:
|
||||
WorkflowToolProviderController.from_db(workflow_tool_provider)
|
||||
except Exception as e:
|
||||
|
||||
@ -505,7 +505,7 @@ def _truncate_container_database(app: Flask) -> None:
|
||||
session_factory-created sessions. Truncating after each test gives the suite
|
||||
a central DB isolation contract that does not depend on which session a test used.
|
||||
This only covers SQLAlchemy application tables in db.metadata for now;
|
||||
object storage and custom ad hoc metadata still need their own cleanup.
|
||||
Redis, object storage, and custom ad hoc metadata still need their own cleanup.
|
||||
"""
|
||||
with app.app_context():
|
||||
db.session.remove()
|
||||
@ -524,27 +524,13 @@ def _truncate_container_database(app: Flask) -> None:
|
||||
db.session.remove()
|
||||
|
||||
|
||||
def _flush_container_redis(app: Flask) -> None:
|
||||
"""
|
||||
Reset Redis after a container integration test.
|
||||
|
||||
Tests in this package share one Redis container for performance. Application
|
||||
code stores temporary tokens, rate-limit counters, locks, and cache entries
|
||||
there, so flushing after each test gives Redis-backed state the same
|
||||
isolation contract as the PostgreSQL container.
|
||||
"""
|
||||
with app.app_context():
|
||||
app.extensions["redis"].flushdb()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None, None, None]:
|
||||
"""
|
||||
Clean DB and Redis state after tests that use the containerized Flask app.
|
||||
Clean DB state after tests that use the containerized Flask app.
|
||||
|
||||
This fixture intentionally does not depend on flask_app_with_containers so
|
||||
tests under this package do not start the full app/container stack just to
|
||||
run state cleanup.
|
||||
non-DB tests under this package do not start the full app/container stack.
|
||||
"""
|
||||
yield
|
||||
|
||||
@ -552,10 +538,7 @@ def isolate_container_database(request: pytest.FixtureRequest) -> Generator[None
|
||||
return
|
||||
|
||||
app = request.getfixturevalue("flask_app_with_containers")
|
||||
try:
|
||||
_truncate_container_database(app)
|
||||
finally:
|
||||
_flush_container_redis(app)
|
||||
_truncate_container_database(app)
|
||||
|
||||
|
||||
@pytest.fixture(scope="package", autouse=True)
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import Account
|
||||
|
||||
ACCOUNT_EMAIL = f"container-state-isolation-{uuid4()}@example.com"
|
||||
REDIS_KEY = f"container-state-isolation:{uuid4()}"
|
||||
|
||||
|
||||
def test_1_container_state_can_be_written(
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
) -> None:
|
||||
account = Account(
|
||||
name="Container State Isolation",
|
||||
email=ACCOUNT_EMAIL,
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
with flask_app_with_containers.app_context():
|
||||
redis_client.set(REDIS_KEY, "leaked")
|
||||
assert redis_client.get(REDIS_KEY) == b"leaked"
|
||||
|
||||
|
||||
def test_2_container_state_is_flushed_between_tests(
|
||||
flask_app_with_containers,
|
||||
db_session_with_containers,
|
||||
) -> None:
|
||||
assert db_session_with_containers.query(Account).filter_by(email=ACCOUNT_EMAIL).one_or_none() is None
|
||||
|
||||
with flask_app_with_containers.app_context():
|
||||
assert redis_client.get(REDIS_KEY) is None
|
||||
@ -1,71 +0,0 @@
|
||||
import json
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from commands.data_migration import (
|
||||
ID_STRATEGY_CHOICES,
|
||||
export_migration_data,
|
||||
export_migration_data_template,
|
||||
import_migration_data,
|
||||
)
|
||||
|
||||
|
||||
def test_export_command_requires_input_and_output():
|
||||
result = CliRunner().invoke(export_migration_data, [])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert export_migration_data.name == "export-app-migration"
|
||||
assert "--input" in result.output
|
||||
assert "--output" in result.output
|
||||
|
||||
|
||||
def test_import_command_requires_input_and_target_tenant_or_package_metadata():
|
||||
result = CliRunner().invoke(import_migration_data, [])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert import_migration_data.name == "import-app-migration"
|
||||
assert "--input" in result.output
|
||||
|
||||
|
||||
def test_import_command_does_not_expose_unimplemented_map_id_strategy():
|
||||
assert ID_STRATEGY_CHOICES == ["preserve-id", "generate-new-id"]
|
||||
|
||||
|
||||
def test_export_template_command_prints_scripted_json_template():
|
||||
result = CliRunner().invoke(export_migration_data_template, [])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert export_migration_data_template.name == "app-migration-template"
|
||||
template = json.loads(result.output)
|
||||
assert template == {
|
||||
"source_tenant": {"mode": "single", "id": "", "name": "admin's Workspace"},
|
||||
"apps": {"modes": ["workflow", "advanced-chat"], "ids": [], "all": True},
|
||||
"include_referenced_tools": True,
|
||||
"additional_tools": {"api_tools": [], "workflow_tools": [], "mcp_tools": []},
|
||||
"include_secrets": False,
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": False,
|
||||
"id_strategy": "preserve-id",
|
||||
"conflict_strategy": "fail",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_export_template_command_writes_output_file(tmp_path):
|
||||
output_file = tmp_path / "export-template.json"
|
||||
|
||||
result = CliRunner().invoke(export_migration_data_template, ["--output", str(output_file)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Output written to {output_file}" in result.output
|
||||
assert json.loads(output_file.read_text())["apps"]["all"] is True
|
||||
|
||||
|
||||
def test_export_template_command_requires_overwrite_for_existing_output(tmp_path):
|
||||
output_file = tmp_path / "export-template.json"
|
||||
output_file.write_text("{}")
|
||||
|
||||
result = CliRunner().invoke(export_migration_data_template, ["--output", str(output_file)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "already exists" in result.output
|
||||
@ -1,384 +0,0 @@
|
||||
from commands.data_migration import (
|
||||
CONFLICT_STRATEGY_CHOICES,
|
||||
ID_STRATEGY_CHOICES,
|
||||
_confirm_wizard_summary,
|
||||
_print_auto_tools,
|
||||
_print_final_tool_selection,
|
||||
_print_wizard_step,
|
||||
_prompt_additional_tools,
|
||||
_prompt_output_file,
|
||||
_prompt_tool_category,
|
||||
_resolve_mcp_tool_names,
|
||||
migration_data_wizard,
|
||||
parse_index_selection,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_index_selection_supports_all():
|
||||
assert parse_index_selection("all", ["a", "b", "c"]) == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_wizard_command_uses_app_migration_name():
|
||||
assert migration_data_wizard.name == "app-migration-wizard"
|
||||
|
||||
|
||||
def test_parse_index_selection_supports_comma_indexes():
|
||||
assert parse_index_selection("1, 3", ["a", "b", "c"]) == ["a", "c"]
|
||||
|
||||
|
||||
def test_print_wizard_step_adds_separator(monkeypatch):
|
||||
output_lines = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
|
||||
_print_wizard_step("App Selection")
|
||||
|
||||
assert output_lines == ["", "==== App Selection ===="]
|
||||
|
||||
|
||||
def test_conflict_strategy_choices_exclude_replace():
|
||||
assert CONFLICT_STRATEGY_CHOICES == ["fail", "skip", "update"]
|
||||
|
||||
|
||||
def test_prompt_app_ids_explains_comma_selection_and_default(monkeypatch):
|
||||
from commands.data_migration import _prompt_app_ids
|
||||
|
||||
prompts = []
|
||||
output_lines = []
|
||||
apps = [
|
||||
type("App", (), {"id": "app-1", "name": "embedded", "mode": "workflow"})(),
|
||||
type("App", (), {"id": "app-2", "name": "main", "mode": "advanced-chat"})(),
|
||||
]
|
||||
|
||||
def capture_prompt(text, **kwargs):
|
||||
prompts.append((text, kwargs))
|
||||
return "1,2"
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
|
||||
|
||||
assert _prompt_app_ids(apps) == ["app-1", "app-2"]
|
||||
assert prompts == [("Select apps by number, comma-separated numbers, or all", {"default": "all"})]
|
||||
assert "Currently supported app types: workflow and chatflow." in output_lines
|
||||
|
||||
|
||||
def test_prompt_tool_category_marks_auto_discovered_tools(monkeypatch):
|
||||
output_lines = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "")
|
||||
|
||||
selected = _prompt_tool_category(
|
||||
"Custom API tools",
|
||||
[("weather", "weather", "tool-id"), ("calendar", "calendar", "calendar-id")],
|
||||
auto_tools={"weather": "tool-id"},
|
||||
)
|
||||
|
||||
assert selected == []
|
||||
assert "1. [auto] weather (tool-id)" in output_lines
|
||||
assert "2. [ ] calendar (calendar-id)" in output_lines
|
||||
assert output_lines[:2] == ["", "==== Custom API tools ===="]
|
||||
|
||||
|
||||
def test_prompt_tool_category_explains_comma_selection_and_default(monkeypatch):
|
||||
prompts = []
|
||||
|
||||
def capture_prompt(text, **kwargs):
|
||||
prompts.append((text, kwargs))
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
|
||||
|
||||
selected = _prompt_tool_category(
|
||||
"Custom API tools",
|
||||
[("weather", "weather", "tool-id")],
|
||||
auto_tools={},
|
||||
)
|
||||
|
||||
assert selected == []
|
||||
assert prompts == [
|
||||
(
|
||||
"Select custom api tools by number, comma-separated numbers, all, or empty",
|
||||
{"default": "", "show_default": "empty"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_prompt_output_file_shows_default(monkeypatch):
|
||||
prompts = []
|
||||
|
||||
def capture_prompt(text, **kwargs):
|
||||
prompts.append((text, kwargs))
|
||||
return "migration-data.json"
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
|
||||
|
||||
assert _prompt_output_file() == ("migration-data.json", False)
|
||||
assert prompts[0][0] == "Output path"
|
||||
assert prompts[0][1]["show_default"] is True
|
||||
|
||||
|
||||
def test_prompt_tool_category_marks_auto_by_detail_and_supports_multi_select(monkeypatch):
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "1,2")
|
||||
|
||||
selected = _prompt_tool_category(
|
||||
"Workflow tools",
|
||||
[("tool-1", "embedded", "app-1"), ("tool-2", "other", "app-2")],
|
||||
auto_tools={"embedded": "app-1"},
|
||||
)
|
||||
|
||||
assert selected == ["tool-1", "tool-2"]
|
||||
|
||||
|
||||
def test_prompt_tool_category_marks_auto_by_value():
|
||||
output_lines = []
|
||||
|
||||
from commands import data_migration
|
||||
|
||||
original_echo = data_migration.click.echo
|
||||
original_prompt = data_migration.click.prompt
|
||||
data_migration.click.echo = output_lines.append
|
||||
data_migration.click.prompt = lambda *args, **kwargs: ""
|
||||
try:
|
||||
_prompt_tool_category(
|
||||
"Workflow tools",
|
||||
[("tool-1", "embedded_workflow_as_tool", "tool-1")],
|
||||
auto_tools={"embedded_workflow_as_tool": "tool-1"},
|
||||
)
|
||||
finally:
|
||||
data_migration.click.echo = original_echo
|
||||
data_migration.click.prompt = original_prompt
|
||||
|
||||
assert "1. [auto] embedded_workflow_as_tool (tool-1)" in output_lines
|
||||
|
||||
|
||||
def test_print_auto_tools_lists_each_category(monkeypatch):
|
||||
output_lines = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
|
||||
_print_auto_tools(
|
||||
{
|
||||
"api_tools": {"weather": "3bac3aa9-dd87-4351-9459-a7099137b028"},
|
||||
"workflow_tools": {"embedded_workflow_as_tool": "e6024578-41b7-4fb5-a81f-9201358e5835"},
|
||||
"mcp_tools": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert "Automatically discovered tools:" in output_lines
|
||||
assert "Custom API tools" in output_lines
|
||||
assert "- weather: 3bac3aa9-dd87-4351-9459-a7099137b028" in output_lines
|
||||
assert "Workflow tools" in output_lines
|
||||
assert "- embedded_workflow_as_tool: e6024578-41b7-4fb5-a81f-9201358e5835" in output_lines
|
||||
assert "MCP tools" in output_lines
|
||||
assert "- none" in output_lines
|
||||
|
||||
|
||||
def test_resolve_mcp_tool_names_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
|
||||
statements = []
|
||||
|
||||
def capture_scalar(statement):
|
||||
statements.append(str(statement))
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.db.session.scalar", capture_scalar)
|
||||
|
||||
assert _resolve_mcp_tool_names("49a99e46-bc2c-4885-91fa-47615f6192b5", {"my-test-mcp": "my-test-mcp"}) == {
|
||||
"my-test-mcp": "my-test-mcp"
|
||||
}
|
||||
assert "tool_mcp_providers.id =" not in statements[0]
|
||||
assert "tool_mcp_providers.server_identifier =" in statements[0]
|
||||
|
||||
|
||||
def test_prompt_additional_tools_prints_final_selection_when_skipped(monkeypatch):
|
||||
output_lines = []
|
||||
confirm_prompts = []
|
||||
|
||||
def capture_confirm(prompt, **kwargs):
|
||||
confirm_prompts.append((prompt, kwargs))
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.confirm", capture_confirm)
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
|
||||
selected = _prompt_additional_tools(
|
||||
"tenant-id",
|
||||
{
|
||||
"api_tools": {"weather": "3bac3aa9-dd87-4351-9459-a7099137b028"},
|
||||
"workflow_tools": {},
|
||||
"mcp_tools": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert selected == {"api_tools": [], "workflow_tools": [], "mcp_tools": []}
|
||||
assert confirm_prompts == [
|
||||
("Export additional tools manually? [y/n, default: n]", {"default": False, "show_default": False})
|
||||
]
|
||||
assert "Final tools to export:" in output_lines
|
||||
assert "- [auto] weather: 3bac3aa9-dd87-4351-9459-a7099137b028" in output_lines
|
||||
|
||||
|
||||
def test_final_tool_selection_deduplicates_manual_tool_already_auto(monkeypatch):
|
||||
output_lines = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
|
||||
_print_final_tool_selection(
|
||||
{
|
||||
"api_tools": {},
|
||||
"workflow_tools": {"embedded_workflow_as_tool": "e6024578-41b7-4fb5-a81f-9201358e5835"},
|
||||
"mcp_tools": {},
|
||||
},
|
||||
{
|
||||
"api_tools": [],
|
||||
"workflow_tools": ["e6024578-41b7-4fb5-a81f-9201358e5835"],
|
||||
"mcp_tools": [],
|
||||
},
|
||||
{"e6024578-41b7-4fb5-a81f-9201358e5835": "embedded_workflow_as_tool: e6024578"},
|
||||
)
|
||||
|
||||
assert "- [auto] embedded_workflow_as_tool: e6024578-41b7-4fb5-a81f-9201358e5835" in output_lines
|
||||
assert not any(line.startswith("- [manual]") for line in output_lines)
|
||||
|
||||
|
||||
def test_prompt_output_file_rejects_yes_no_typo(monkeypatch):
|
||||
import click
|
||||
import pytest
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", lambda *args, **kwargs: "y")
|
||||
|
||||
with pytest.raises(click.ClickException, match="Output path must be a file path"):
|
||||
_prompt_output_file()
|
||||
|
||||
|
||||
def test_confirm_wizard_summary_shows_conflict_strategy(monkeypatch):
|
||||
output_lines = []
|
||||
confirm_prompts = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
monkeypatch.setattr(
|
||||
"commands.data_migration.click.confirm",
|
||||
lambda prompt, **kwargs: confirm_prompts.append((prompt, kwargs)) or True,
|
||||
)
|
||||
|
||||
_confirm_wizard_summary(
|
||||
tenant_name="admin's Workspace",
|
||||
app_names=["main_chatflow"],
|
||||
auto_tools={"api_tools": {}, "workflow_tools": {}, "mcp_tools": {}},
|
||||
additional_tools={"api_tools": [], "workflow_tools": [], "mcp_tools": []},
|
||||
manual_labels={},
|
||||
include_referenced_tools=True,
|
||||
include_secrets=False,
|
||||
create_tokens=True,
|
||||
id_strategy="preserve-id",
|
||||
conflict_strategy="fail",
|
||||
output_file="migration-data.json",
|
||||
)
|
||||
|
||||
assert "id strategy: preserve-id" in output_lines
|
||||
assert "conflict strategy: fail" in output_lines
|
||||
assert confirm_prompts == [("Write migration package? [y/n, default: y]", {"default": True, "show_default": False})]
|
||||
|
||||
|
||||
def test_confirm_wizard_summary_shows_final_deduplicated_tool_selection(monkeypatch):
|
||||
output_lines = []
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
monkeypatch.setattr("commands.data_migration.click.confirm", lambda *args, **kwargs: True)
|
||||
|
||||
_confirm_wizard_summary(
|
||||
tenant_name="admin's Workspace",
|
||||
app_names=["main_chatflow"],
|
||||
auto_tools={
|
||||
"api_tools": {"weather": "weather-id"},
|
||||
"workflow_tools": {"embedded_workflow_as_tool": "workflow-tool-id"},
|
||||
"mcp_tools": {},
|
||||
},
|
||||
additional_tools={
|
||||
"api_tools": ["weather-id", "calendar"],
|
||||
"workflow_tools": [],
|
||||
"mcp_tools": ["mcp-id"],
|
||||
},
|
||||
manual_labels={
|
||||
"calendar": "calendar: calendar-id",
|
||||
"mcp-id": "my-test-mcp: mcp-id",
|
||||
},
|
||||
include_referenced_tools=True,
|
||||
include_secrets=False,
|
||||
create_tokens=False,
|
||||
id_strategy="preserve-id",
|
||||
conflict_strategy="update",
|
||||
output_file="migration-data.json",
|
||||
)
|
||||
|
||||
assert "Final tools to export:" in output_lines
|
||||
assert "Custom API tools" in output_lines
|
||||
assert "- [auto] weather: weather-id" in output_lines
|
||||
assert "- [manual] calendar: calendar-id" in output_lines
|
||||
assert "Workflow tools" in output_lines
|
||||
assert "- [auto] embedded_workflow_as_tool: workflow-tool-id" in output_lines
|
||||
assert "MCP tools" in output_lines
|
||||
assert "- [manual] my-test-mcp: mcp-id" in output_lines
|
||||
assert not any(line.startswith("additional api tools:") for line in output_lines)
|
||||
assert not any(line.startswith("additional workflow tools:") for line in output_lines)
|
||||
assert not any(line.startswith("additional mcp tools:") for line in output_lines)
|
||||
assert "- [manual] weather-id" not in output_lines
|
||||
|
||||
|
||||
def test_import_options_prompts_explain_secrets_reuse_and_conflicts(monkeypatch):
|
||||
from commands.data_migration import _prompt_import_options
|
||||
|
||||
output_lines = []
|
||||
confirm_prompts = []
|
||||
prompt_calls = []
|
||||
|
||||
def capture_confirm(prompt, **kwargs):
|
||||
confirm_prompts.append((prompt, kwargs))
|
||||
return False
|
||||
|
||||
def capture_prompt(prompt, **kwargs):
|
||||
prompt_calls.append((prompt, kwargs))
|
||||
return kwargs["default"]
|
||||
|
||||
monkeypatch.setattr("commands.data_migration.click.echo", output_lines.append)
|
||||
monkeypatch.setattr("commands.data_migration.click.confirm", capture_confirm)
|
||||
monkeypatch.setattr("commands.data_migration.click.prompt", capture_prompt)
|
||||
|
||||
include_secrets, create_tokens, id_strategy, conflict_strategy = _prompt_import_options()
|
||||
|
||||
assert include_secrets is False
|
||||
assert create_tokens is False
|
||||
assert id_strategy == "preserve-id"
|
||||
assert conflict_strategy == "update"
|
||||
assert "Secrets include workflow/app DSL secret values, custom API tool credentials," in output_lines
|
||||
assert "-- Secrets --" in output_lines
|
||||
assert "If you choose no, credentials are omitted or masked," in output_lines
|
||||
assert "-- App API Tokens --" in output_lines
|
||||
assert "When enabled, import will create an app API token if the imported app has none," in output_lines
|
||||
assert "or reuse an existing app API token if one already exists." in output_lines
|
||||
assert "-- ID Strategy --" in output_lines
|
||||
assert "ID strategy controls whether imported app and tool IDs preserve source IDs" in output_lines
|
||||
assert "or use target-generated IDs." in output_lines
|
||||
assert "preserve-id: keep source IDs where the target service supports it." in output_lines
|
||||
assert (
|
||||
"generate-new-id: let the target environment generate new IDs and rewrite references via mapping."
|
||||
in output_lines
|
||||
)
|
||||
assert "-- Conflict Strategy --" in output_lines
|
||||
assert "Conflict strategy controls what import does when a target resource already exists." in output_lines
|
||||
assert "fail: stop at the first conflict; previously committed resources are not rolled back." in output_lines
|
||||
assert "skip: keep the existing target resource and skip importing that resource." in output_lines
|
||||
assert "update: update the existing target resource in place." in output_lines
|
||||
assert confirm_prompts == [
|
||||
("Include secrets in output JSON? [y/n, default: n]", {"default": False, "show_default": False}),
|
||||
("Create or reuse app API tokens during import? [y/n, default: n]", {"default": False, "show_default": False}),
|
||||
]
|
||||
assert prompt_calls[0][0] == "Import ID strategy. Enter one of: preserve-id, generate-new-id"
|
||||
assert prompt_calls[0][1]["default"] == "preserve-id"
|
||||
assert prompt_calls[0][1]["show_default"] is True
|
||||
assert prompt_calls[0][1]["type"].choices == ID_STRATEGY_CHOICES
|
||||
assert prompt_calls[1][0] == "Import conflict strategy. Enter one of: fail, skip, update"
|
||||
assert prompt_calls[1][1]["default"] == "update"
|
||||
assert prompt_calls[1][1]["show_default"] is True
|
||||
assert prompt_calls[1][1]["type"].choices == CONFLICT_STRATEGY_CHOICES
|
||||
@ -1036,48 +1036,6 @@ class TestSegmentListAdvancedCases:
|
||||
assert status == 200
|
||||
assert response["total"] == 1
|
||||
|
||||
def test_segment_list_postgres_keyword_filter_handles_scalar_keywords(self, app: Flask):
|
||||
api = DatasetDocumentSegmentListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
dataset = MagicMock()
|
||||
document = MagicMock()
|
||||
pagination = MagicMock(items=[], total=0, pages=0)
|
||||
|
||||
with (
|
||||
app.test_request_context("/?keyword=test"),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.current_account_with_tenant",
|
||||
return_value=(MagicMock(), "11111111-1111-1111-1111-111111111111"),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.DatasetService.get_dataset",
|
||||
return_value=dataset,
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.DatasetService.check_dataset_permission",
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.DocumentService.get_document",
|
||||
return_value=document,
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.dify_config",
|
||||
SimpleNamespace(SQLALCHEMY_DATABASE_URI_SCHEME="postgresql"),
|
||||
),
|
||||
patch(
|
||||
"controllers.console.datasets.datasets_segments.db.paginate",
|
||||
return_value=pagination,
|
||||
) as paginate_mock,
|
||||
):
|
||||
method(api, "22222222-2222-2222-2222-222222222222", "33333333-3333-3333-3333-333333333333")
|
||||
|
||||
query = paginate_mock.call_args.kwargs["select"]
|
||||
sql = str(query.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "jsonb_array_elements_text(CASE" in sql
|
||||
assert "ELSE CAST('[]' AS JSONB)" in sql
|
||||
|
||||
def test_segment_list_permission_denied(self, app: Flask):
|
||||
"""Test segment list with permission denied"""
|
||||
api = DatasetDocumentSegmentListApi()
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.tools.errors import ToolProviderNotFoundError
|
||||
from events.event_handlers import delete_tool_parameters_cache_when_sync_draft_workflow as handler_module
|
||||
|
||||
|
||||
def test_missing_tool_provider_does_not_log_error_traceback(monkeypatch, caplog):
|
||||
app = SimpleNamespace(id="workflow-id", tenant_id="tenant-id")
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-id",
|
||||
"data": {
|
||||
"type": "tool",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
tool_entity = SimpleNamespace(
|
||||
provider_type=SimpleNamespace(value="mcp"),
|
||||
provider_id="my-test-mcp-server",
|
||||
provider_name="my-test-mcp-server",
|
||||
tool_name="echo",
|
||||
credential_id=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(handler_module, "adapt_node_config_for_graph", lambda node_data: {"data": node_data["data"]})
|
||||
monkeypatch.setattr(handler_module.ToolEntity, "model_validate", lambda data: tool_entity)
|
||||
monkeypatch.setattr(
|
||||
handler_module.ToolManager,
|
||||
"get_tool_runtime",
|
||||
lambda **kwargs: (_ for _ in ()).throw(ToolProviderNotFoundError("mcp provider not found")),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=handler_module.logger.name):
|
||||
handler_module.handle(app, synced_draft_workflow=workflow)
|
||||
|
||||
assert not [record for record in caplog.records if record.levelno >= logging.ERROR]
|
||||
assert "Skipped deleting tool parameters cache" in caplog.text
|
||||
@ -1,115 +0,0 @@
|
||||
from services.data_migration.dependency_discovery_service import DependencyDiscoveryService
|
||||
from services.data_migration.entities import DependencyKind
|
||||
|
||||
|
||||
def test_discovers_and_deduplicates_standalone_tool_nodes():
|
||||
graph = {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{"data": {"type": "tool", "provider_type": "api", "provider_id": "weather"}},
|
||||
{"data": {"type": "tool", "provider_type": "api", "provider_id": "weather"}},
|
||||
{"data": {"type": "tool", "provider_type": "workflow", "provider_id": "wf-tool-1"}},
|
||||
{"data": {"type": "tool", "provider_type": "builtin", "provider_id": "google_search"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
dependencies = DependencyDiscoveryService().discover_from_dsl(graph)
|
||||
|
||||
assert [(item.kind, item.provider_id) for item in dependencies] == [
|
||||
(DependencyKind.API_TOOL, "weather"),
|
||||
(DependencyKind.WORKFLOW_TOOL, "wf-tool-1"),
|
||||
(DependencyKind.BUILTIN_OR_PLUGIN_TOOL, "google_search"),
|
||||
]
|
||||
|
||||
|
||||
def test_discovers_agent_node_tools():
|
||||
graph = {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"tools": [
|
||||
{"provider_type": "mcp", "provider_id": "mcp-1"},
|
||||
{"provider_type": "api", "provider_id": "api-1"},
|
||||
],
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
dependencies = DependencyDiscoveryService().discover_from_dsl(graph)
|
||||
|
||||
assert [(item.kind, item.provider_id) for item in dependencies] == [
|
||||
(DependencyKind.MCP_TOOL, "mcp-1"),
|
||||
(DependencyKind.API_TOOL, "api-1"),
|
||||
]
|
||||
|
||||
|
||||
def test_discovers_tool_nodes_from_exported_workflow_dsl_shape():
|
||||
dsl = {
|
||||
"workflow": {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"provider_type": "api",
|
||||
"provider_id": "api-provider-id",
|
||||
"provider_name": "weather",
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"provider_type": "workflow",
|
||||
"provider_id": "workflow-tool-id",
|
||||
"provider_name": "embedded_workflow",
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies = DependencyDiscoveryService().discover_from_dsl(dsl)
|
||||
|
||||
assert [(item.kind, item.provider_id, item.provider_name) for item in dependencies] == [
|
||||
(DependencyKind.API_TOOL, "api-provider-id", "weather"),
|
||||
(DependencyKind.WORKFLOW_TOOL, "workflow-tool-id", "embedded_workflow"),
|
||||
]
|
||||
|
||||
|
||||
def test_discovers_agent_tools_from_exported_agent_parameter_shape():
|
||||
dsl = {
|
||||
"workflow": {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"agent_parameters": {
|
||||
"tools": {
|
||||
"value": [
|
||||
{
|
||||
"provider_type": "api",
|
||||
"provider_id": "api-provider-id",
|
||||
"provider_name": "weather",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies = DependencyDiscoveryService().discover_from_dsl(dsl)
|
||||
|
||||
assert [(item.kind, item.provider_id, item.provider_name) for item in dependencies] == [
|
||||
(DependencyKind.API_TOOL, "api-provider-id", "weather"),
|
||||
]
|
||||
@ -1,71 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
IdStrategy,
|
||||
ImportOptions,
|
||||
MigrationDataError,
|
||||
MigrationMetadata,
|
||||
MigrationPackage,
|
||||
SourceTenant,
|
||||
)
|
||||
|
||||
|
||||
def test_import_options_defaults_match_spec():
|
||||
options = ImportOptions()
|
||||
|
||||
assert options.create_app_api_token_on_import is False
|
||||
assert options.id_strategy == IdStrategy.PRESERVE_ID
|
||||
assert options.conflict_strategy == ConflictStrategy.FAIL
|
||||
|
||||
|
||||
def test_metadata_requires_version():
|
||||
with pytest.raises(MigrationDataError, match="metadata.version"):
|
||||
MigrationMetadata.from_mapping({})
|
||||
|
||||
|
||||
def test_metadata_parses_source_tenants_and_import_options():
|
||||
metadata = MigrationMetadata.from_mapping(
|
||||
{
|
||||
"version": "1",
|
||||
"source_scope": "single",
|
||||
"source_tenants": [{"id": "tenant-1", "name": "source"}],
|
||||
"target_tenant": {"name": "prod"},
|
||||
"include_secrets": False,
|
||||
"import_options": {"conflict_strategy": "update"},
|
||||
}
|
||||
)
|
||||
|
||||
assert metadata.version == "1"
|
||||
assert metadata.source_scope == "single"
|
||||
assert metadata.source_tenants == [SourceTenant(id="tenant-1", name="source")]
|
||||
assert metadata.target_tenant == {"name": "prod"}
|
||||
assert metadata.import_options.conflict_strategy == ConflictStrategy.UPDATE
|
||||
assert metadata.import_options.id_strategy == IdStrategy.PRESERVE_ID
|
||||
|
||||
|
||||
def test_import_options_invalid_strategy_raises_domain_error():
|
||||
with pytest.raises(MigrationDataError, match="id_strategy"):
|
||||
ImportOptions.from_mapping({"id_strategy": "unknown"})
|
||||
|
||||
with pytest.raises(MigrationDataError, match="id_strategy"):
|
||||
ImportOptions.from_mapping({"id_strategy": "map-id"})
|
||||
|
||||
with pytest.raises(MigrationDataError, match="conflict_strategy"):
|
||||
ImportOptions.from_mapping({"conflict_strategy": "unknown"})
|
||||
|
||||
with pytest.raises(MigrationDataError, match="conflict_strategy"):
|
||||
ImportOptions.from_mapping({"conflict_strategy": "replace"})
|
||||
|
||||
|
||||
def test_metadata_rejects_invalid_target_tenant_shape():
|
||||
with pytest.raises(MigrationDataError, match="target_tenant"):
|
||||
MigrationMetadata.from_mapping({"version": "1", "target_tenant": "prod"})
|
||||
|
||||
|
||||
def test_migration_package_sections_must_be_lists_of_objects():
|
||||
with pytest.raises(MigrationDataError, match="workflows"):
|
||||
MigrationPackage.from_mapping({"metadata": {"version": "1"}, "workflows": {"id": "app-1"}})
|
||||
|
||||
with pytest.raises(MigrationDataError, match="tools"):
|
||||
MigrationPackage.from_mapping({"metadata": {"version": "1"}, "tools": ["weather"]})
|
||||
@ -1,201 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from services.data_migration.dependency_discovery_service import DiscoveredDependency
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
DependencyKind,
|
||||
IdStrategy,
|
||||
MigrationDataError,
|
||||
ResourceType,
|
||||
)
|
||||
from services.data_migration.export_service import ExportConfigParser, MigrationExportService
|
||||
|
||||
|
||||
def test_export_config_parser_accepts_new_scripted_shape():
|
||||
selection = ExportConfigParser().parse(
|
||||
{
|
||||
"source_tenant": {"mode": "single", "name": "admin's Workspace"},
|
||||
"apps": {"modes": ["workflow", "advanced-chat"], "ids": ["app-1"], "all": False},
|
||||
"include_referenced_tools": True,
|
||||
"additional_tools": {
|
||||
"api_tools": ["weather"],
|
||||
"workflow_tools": ["workflow-tool-1"],
|
||||
"mcp_tools": ["mcp-1"],
|
||||
},
|
||||
"include_secrets": False,
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": True,
|
||||
"id_strategy": "preserve-id",
|
||||
"conflict_strategy": "fail",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert selection.source_tenant_name == "admin's Workspace"
|
||||
assert selection.app_ids == ["app-1"]
|
||||
assert selection.export_all_apps is False
|
||||
assert selection.additional_api_tools == ["weather"]
|
||||
assert selection.additional_workflow_tools == ["workflow-tool-1"]
|
||||
assert selection.additional_mcp_tools == ["mcp-1"]
|
||||
assert selection.include_secrets is False
|
||||
assert selection.import_options.create_app_api_token_on_import is True
|
||||
assert selection.import_options.id_strategy == IdStrategy.PRESERVE_ID
|
||||
assert selection.import_options.conflict_strategy == ConflictStrategy.FAIL
|
||||
|
||||
|
||||
def test_export_config_parser_defaults_to_secret_free_all_apps():
|
||||
selection = ExportConfigParser().parse(
|
||||
{
|
||||
"source_tenant": {"name": "source"},
|
||||
"apps": {"all": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert selection.export_all_apps is True
|
||||
assert selection.include_referenced_tools is True
|
||||
assert selection.include_secrets is False
|
||||
|
||||
|
||||
def test_export_config_parser_requires_explicit_source_tenant_name_for_new_shape():
|
||||
with pytest.raises(MigrationDataError, match="source_tenant.name"):
|
||||
ExportConfigParser().parse({"source_tenant": {"mode": "single"}, "apps": {"all": True}})
|
||||
|
||||
|
||||
def test_export_config_parser_accepts_limited_backwards_draft_shape():
|
||||
selection = ExportConfigParser().parse(
|
||||
{
|
||||
"tenant_name": "legacy-source",
|
||||
"workflows": ["app-1"],
|
||||
"tools": ["weather"],
|
||||
"workflow_tools": ["wf-tool-1"],
|
||||
"mcp_tools": ["mcp-1"],
|
||||
"export_all_workflows": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert selection.source_tenant_name == "legacy-source"
|
||||
assert selection.app_ids == ["app-1"]
|
||||
assert selection.additional_api_tools == ["weather"]
|
||||
assert selection.additional_workflow_tools == ["wf-tool-1"]
|
||||
assert selection.additional_mcp_tools == ["mcp-1"]
|
||||
assert selection.include_secrets is False
|
||||
|
||||
|
||||
def test_export_config_parser_rejects_unsupported_app_modes():
|
||||
with pytest.raises(MigrationDataError, match="Unsupported app modes"):
|
||||
ExportConfigParser().parse(
|
||||
{
|
||||
"source_tenant": {"name": "source"},
|
||||
"apps": {"modes": ["chat"], "all": True},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_secret_free_api_tool_export_uses_masking_and_omits_credentials(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_get_api_provider(provider: str, tenant_id: str, mask: bool = True):
|
||||
calls.append((provider, tenant_id, mask))
|
||||
return {"credentials": {"api_key": "masked"}, "schema": {"openapi": "3.0.0"}, "tools": ["unused"]}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.data_migration.export_service.ToolManager.user_get_api_provider",
|
||||
fake_get_api_provider,
|
||||
)
|
||||
service = MigrationExportService()
|
||||
tools: list[dict] = []
|
||||
report_items = []
|
||||
|
||||
service._export_api_tools(
|
||||
"tenant-1",
|
||||
["weather"],
|
||||
include_secrets=False,
|
||||
exported_tools=tools,
|
||||
report_items=report_items,
|
||||
)
|
||||
|
||||
assert calls == [("weather", "tenant-1", True)]
|
||||
assert tools == [{"schema": {"openapi": "3.0.0"}, "provider_name": "weather", "source_tenant_id": "tenant-1"}]
|
||||
assert report_items[0].resource_type == ResourceType.API_TOOL
|
||||
|
||||
|
||||
def test_secret_free_mcp_dependencies_are_dependency_only():
|
||||
service = MigrationExportService()
|
||||
dependencies: list[dict] = []
|
||||
mcp_tools: list[dict] = []
|
||||
report_items = []
|
||||
|
||||
service._export_mcp_tools(
|
||||
tenant_id="tenant-1",
|
||||
provider_ids=["mcp-1"],
|
||||
include_secrets=False,
|
||||
exported_mcp_tools=mcp_tools,
|
||||
dependencies=dependencies,
|
||||
report_items=report_items,
|
||||
)
|
||||
|
||||
assert mcp_tools == []
|
||||
assert dependencies == [
|
||||
{
|
||||
"kind": DependencyKind.MCP_TOOL.value,
|
||||
"provider_id": "mcp-1",
|
||||
"provider_name": None,
|
||||
"source": "mcp_provider",
|
||||
}
|
||||
]
|
||||
assert report_items[0].status == "dependency-only"
|
||||
assert report_items[0].name == "mcp_tool mcp-1"
|
||||
|
||||
|
||||
def test_get_mcp_provider_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
|
||||
statements = []
|
||||
|
||||
def capture_scalar(statement):
|
||||
statements.append(str(statement))
|
||||
|
||||
monkeypatch.setattr("services.data_migration.export_service.db.session.scalar", capture_scalar)
|
||||
|
||||
with pytest.raises(MigrationDataError, match="MCP provider not found"):
|
||||
MigrationExportService()._get_mcp_provider("tenant-1", "my-test-mcp")
|
||||
|
||||
assert len(statements) == 1
|
||||
assert "tool_mcp_providers.id =" not in statements[0]
|
||||
assert "tool_mcp_providers.server_identifier =" in statements[0]
|
||||
|
||||
|
||||
def test_dependency_ids_are_deduplicated_with_manual_selection_first():
|
||||
service = MigrationExportService()
|
||||
provider_ids = service._provider_ids(
|
||||
manual_provider_ids=["weather", "weather", "manual"],
|
||||
discovered_dependencies=[
|
||||
DiscoveredDependency(DependencyKind.API_TOOL, "weather"),
|
||||
DiscoveredDependency(DependencyKind.API_TOOL, "forecast"),
|
||||
DiscoveredDependency(DependencyKind.WORKFLOW_TOOL, "workflow-tool"),
|
||||
],
|
||||
kind=DependencyKind.API_TOOL,
|
||||
)
|
||||
|
||||
assert provider_ids == ["weather", "manual", "forecast"]
|
||||
|
||||
|
||||
def test_api_provider_ids_use_provider_name_from_discovered_dependencies():
|
||||
service = MigrationExportService()
|
||||
provider_ids = service._provider_ids(
|
||||
manual_provider_ids=[],
|
||||
discovered_dependencies=[
|
||||
DiscoveredDependency(DependencyKind.API_TOOL, "api-provider-id", provider_name="weather"),
|
||||
],
|
||||
kind=DependencyKind.API_TOOL,
|
||||
)
|
||||
|
||||
assert provider_ids == ["weather"]
|
||||
|
||||
|
||||
def test_mcp_authentication_export_omits_runtime_header_shape():
|
||||
service = MigrationExportService()
|
||||
|
||||
assert service._serialize_mcp_authentication({"Authorization": "Bearer token"}) is None
|
||||
assert service._serialize_mcp_authentication({"client_id": "id", "client_secret": "secret"}) == {
|
||||
"client_id": "id",
|
||||
"client_secret": "secret",
|
||||
}
|
||||
@ -1,973 +0,0 @@
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from models.tools import MCPToolProvider, WorkflowToolProvider
|
||||
from services.app_dsl_service import Import
|
||||
from services.data_migration.entities import (
|
||||
ConflictStrategy,
|
||||
IdStrategy,
|
||||
ImportOptions,
|
||||
ImportTarget,
|
||||
MigrationDataError,
|
||||
MigrationPackage,
|
||||
ResourceIdMapping,
|
||||
ResourceReportItem,
|
||||
ResourceType,
|
||||
)
|
||||
from services.data_migration.import_service import ImportRequest, ImportTargetResolver, MigrationImportService
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
|
||||
|
||||
def test_target_tenant_precedence_cli_then_config_then_package():
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1",
|
||||
"source_scope": "single",
|
||||
"source_tenants": [],
|
||||
"target_tenant": {"name": "from-package"},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
resolver = ImportTargetResolver()
|
||||
|
||||
assert (
|
||||
resolver.select_target_tenant_name(
|
||||
ImportRequest(package=package, cli_target_tenant="from-cli", config_target_tenant="from-config")
|
||||
)
|
||||
== "from-cli"
|
||||
)
|
||||
assert (
|
||||
resolver.select_target_tenant_name(
|
||||
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant="from-config")
|
||||
)
|
||||
== "from-config"
|
||||
)
|
||||
assert (
|
||||
resolver.select_target_tenant_name(
|
||||
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant=None)
|
||||
)
|
||||
== "from-package"
|
||||
)
|
||||
|
||||
|
||||
def test_target_tenant_missing_fails_before_import():
|
||||
package = MigrationPackage.from_mapping({"metadata": {"version": "1", "source_scope": "single"}})
|
||||
|
||||
with pytest.raises(MigrationDataError, match="Target tenant"):
|
||||
ImportTargetResolver().select_target_tenant_name(
|
||||
ImportRequest(package=package, cli_target_tenant=None, config_target_tenant=None)
|
||||
)
|
||||
|
||||
|
||||
def test_package_target_tenant_id_can_be_used_without_name():
|
||||
package = MigrationPackage.from_mapping(
|
||||
{"metadata": {"version": "1", "source_scope": "single", "target_tenant": {"id": "tenant-id"}}}
|
||||
)
|
||||
|
||||
assert ImportTargetResolver().select_target_tenant_name(ImportRequest(package=package)) == "tenant-id"
|
||||
|
||||
|
||||
def test_target_tenant_name_is_not_treated_as_uuid():
|
||||
resolver = ImportTargetResolver()
|
||||
|
||||
assert resolver._is_uuid("admin's Workspace") is False
|
||||
assert resolver._is_uuid("49a99e46-bc2c-4885-91fa-47615f6192b5") is True
|
||||
|
||||
|
||||
def test_package_target_tenant_id_ignores_invalid_uuid(monkeypatch):
|
||||
package = MigrationPackage.from_mapping(
|
||||
{"metadata": {"version": "1", "source_scope": "single", "target_tenant": {"id": "not-a-uuid"}}}
|
||||
)
|
||||
|
||||
class StubSession:
|
||||
def get(self, model, identifier):
|
||||
raise AssertionError("invalid UUID should not be passed to session.get")
|
||||
|
||||
def scalars(self, statement):
|
||||
class EmptyResult:
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
return EmptyResult()
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
with pytest.raises(MigrationDataError, match="Target tenant not found"):
|
||||
ImportTargetResolver().resolve(ImportRequest(package=package))
|
||||
|
||||
|
||||
def test_options_override_replaces_package_defaults():
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {
|
||||
"version": "1",
|
||||
"source_scope": "single",
|
||||
"target_tenant": {"name": "target"},
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": True,
|
||||
"conflict_strategy": "update",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
captured_options: list[ImportOptions] = []
|
||||
|
||||
class StubResolver(ImportTargetResolver):
|
||||
def resolve(self, request: ImportRequest) -> ImportTarget:
|
||||
return ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
)
|
||||
|
||||
class CapturingImportService(MigrationImportService):
|
||||
def _import_workflows(
|
||||
self,
|
||||
package: MigrationPackage,
|
||||
target: ImportTarget,
|
||||
options: ImportOptions,
|
||||
report_items: list[ResourceReportItem],
|
||||
id_mapping: dict[str, str],
|
||||
**kwargs,
|
||||
) -> None:
|
||||
captured_options.append(options)
|
||||
|
||||
override = ImportOptions(create_app_api_token_on_import=False, conflict_strategy=ConflictStrategy.SKIP)
|
||||
|
||||
CapturingImportService(target_resolver=StubResolver()).import_package(
|
||||
ImportRequest(package=package, options_override=override)
|
||||
)
|
||||
|
||||
assert captured_options == [override]
|
||||
|
||||
|
||||
def test_only_preserve_id_strategy_reuses_source_app_id():
|
||||
service = MigrationImportService()
|
||||
|
||||
assert service._should_preserve_source_app_id(ImportOptions(id_strategy=IdStrategy.PRESERVE_ID)) is True
|
||||
assert service._should_preserve_source_app_id(ImportOptions(id_strategy=IdStrategy.GENERATE_NEW_ID)) is False
|
||||
|
||||
|
||||
def test_find_existing_app_ignores_invalid_uuid(monkeypatch):
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
raise AssertionError("invalid UUID should not be queried against App.id")
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
assert MigrationImportService()._find_existing_app("not-a-uuid", "tenant-1") is None
|
||||
|
||||
|
||||
def test_find_existing_workflow_tool_does_not_compare_invalid_uuid(monkeypatch):
|
||||
captured = []
|
||||
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
captured.append(statement)
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
MigrationImportService()._find_existing_workflow_tool("tenant-1", "not-a-uuid", "tool-name", "app-id")
|
||||
|
||||
where_clause = str(captured[0].whereclause)
|
||||
assert f"{WorkflowToolProvider.__tablename__}.id" not in where_clause
|
||||
|
||||
|
||||
def test_find_existing_mcp_tool_does_not_compare_invalid_uuid(monkeypatch):
|
||||
captured = []
|
||||
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
captured.append(statement)
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
MigrationImportService()._find_existing_mcp_tool("tenant-1", "my-test-mcp", "my-test-mcp")
|
||||
|
||||
where_clause = str(captured[0].whereclause)
|
||||
assert f"{MCPToolProvider.__tablename__}.id" not in where_clause
|
||||
assert f"{MCPToolProvider.__tablename__}.name" not in where_clause
|
||||
|
||||
|
||||
def test_workflow_app_import_does_not_wrap_app_dsl_import_in_nested_transaction(monkeypatch):
|
||||
class FailingNestedTransaction:
|
||||
def __enter__(self):
|
||||
raise AssertionError("nested transaction should not be opened")
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
return False
|
||||
|
||||
class StubSession:
|
||||
def begin_nested(self):
|
||||
return FailingNestedTransaction()
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
class StubAppDslService:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def import_app(self, **kwargs):
|
||||
return Import(id="import-id", status=ImportStatus.COMPLETED, app_id="imported-app-id")
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(import_service, "AppDslService", StubAppDslService)
|
||||
|
||||
imported_app_id = MigrationImportService()._import_workflow_app(
|
||||
account=object(),
|
||||
workflow_data={"name": "main_chatflow"},
|
||||
dsl_content="app:\n mode: workflow\n",
|
||||
app_id="source-app-id",
|
||||
existing_app=None,
|
||||
options=ImportOptions(id_strategy=IdStrategy.PRESERVE_ID),
|
||||
)
|
||||
|
||||
assert imported_app_id == "imported-app-id"
|
||||
|
||||
|
||||
def test_rewrite_workflow_dsl_replaces_tool_provider_ids():
|
||||
dsl_content = yaml.safe_dump(
|
||||
{
|
||||
"app": {"mode": "workflow"},
|
||||
"workflow": {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"provider_id": "source-api-provider-id",
|
||||
"provider_name": "weather",
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"agent_parameters": {
|
||||
"tools": {
|
||||
"value": [
|
||||
{
|
||||
"provider_id": "source-agent-provider-id",
|
||||
"provider_name": "agent_weather",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
rewritten = MigrationImportService()._rewrite_workflow_dsl_provider_ids(
|
||||
dsl_content,
|
||||
{
|
||||
"source-api-provider-id": "target-api-provider-id",
|
||||
"source-agent-provider-id": "target-agent-provider-id",
|
||||
},
|
||||
)
|
||||
graph = yaml.safe_load(rewritten)["workflow"]["graph"]
|
||||
|
||||
assert graph["nodes"][0]["data"]["provider_id"] == "target-api-provider-id"
|
||||
assert (
|
||||
graph["nodes"][1]["data"]["agent_parameters"]["tools"]["value"][0]["provider_id"] == "target-agent-provider-id"
|
||||
)
|
||||
|
||||
|
||||
def test_source_api_provider_ids_are_discovered_from_workflow_dsl():
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"workflows": [
|
||||
{
|
||||
"dsl": yaml.safe_dump(
|
||||
{
|
||||
"workflow": {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"provider_id": "source-api-provider-id",
|
||||
"provider_name": "weather",
|
||||
"provider_type": "api",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert MigrationImportService()._source_api_provider_ids_by_name(package) == {"weather": {"source-api-provider-id"}}
|
||||
|
||||
|
||||
def test_workflow_tool_import_publishes_referenced_app_before_create(monkeypatch):
|
||||
events = []
|
||||
account = type("Account", (), {"id": "account-1"})()
|
||||
|
||||
class StubSession:
|
||||
def get(self, model, identifier):
|
||||
return account
|
||||
|
||||
class PublishingImportService(MigrationImportService):
|
||||
def _find_existing_app(self, app_id, tenant_id):
|
||||
return object()
|
||||
|
||||
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
|
||||
if ("created", app_id) in events:
|
||||
return type("WorkflowToolProvider", (), {"id": workflow_tool_id or "created-workflow-tool-id"})()
|
||||
return None
|
||||
|
||||
def _ensure_workflow_app_is_published(self, target, account, app_id):
|
||||
events.append(("published", app_id))
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(
|
||||
import_service.WorkflowToolManageService,
|
||||
"create_workflow_tool",
|
||||
lambda **kwargs: events.append(("created", kwargs["workflow_app_id"])),
|
||||
)
|
||||
|
||||
PublishingImportService()._import_workflow_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"workflow_tools": [
|
||||
{
|
||||
"id": "workflow-tool-1",
|
||||
"name": "embedded_workflow_as_tool",
|
||||
"app_id": "workflow-app-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(),
|
||||
{},
|
||||
[],
|
||||
[],
|
||||
)
|
||||
|
||||
assert events == [("published", "workflow-app-1"), ("created", "workflow-app-1")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("id_strategy", "expected_import_id"),
|
||||
[
|
||||
(IdStrategy.PRESERVE_ID, "source-workflow-tool-id"),
|
||||
(IdStrategy.GENERATE_NEW_ID, ""),
|
||||
],
|
||||
)
|
||||
def test_workflow_tool_import_id_follows_id_strategy(monkeypatch, id_strategy, expected_import_id):
|
||||
created_kwargs = []
|
||||
target_provider = type("WorkflowToolProvider", (), {"id": "target-workflow-tool-id"})()
|
||||
account = type("Account", (), {"id": "account-1"})()
|
||||
id_mapping = {"source-app-id": "target-app-id"}
|
||||
id_mapping_details = []
|
||||
|
||||
class StubSession:
|
||||
def get(self, model, identifier):
|
||||
return account
|
||||
|
||||
class StrategyImportService(MigrationImportService):
|
||||
def _find_existing_app(self, app_id, tenant_id):
|
||||
return object()
|
||||
|
||||
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
|
||||
return target_provider if created_kwargs else None
|
||||
|
||||
def _ensure_workflow_app_is_published(self, target, account, app_id):
|
||||
return None
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(
|
||||
import_service.WorkflowToolManageService,
|
||||
"create_workflow_tool",
|
||||
lambda **kwargs: created_kwargs.append(kwargs),
|
||||
)
|
||||
|
||||
StrategyImportService()._import_workflow_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"workflow_tools": [
|
||||
{
|
||||
"id": "source-workflow-tool-id",
|
||||
"name": "embedded_workflow_as_tool",
|
||||
"app_id": "source-app-id",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(id_strategy=id_strategy),
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
[],
|
||||
)
|
||||
|
||||
assert created_kwargs[0]["import_id"] == expected_import_id
|
||||
assert id_mapping["source-workflow-tool-id"] == "target-workflow-tool-id"
|
||||
assert id_mapping_details == [
|
||||
ResourceIdMapping(
|
||||
ResourceType.WORKFLOW_TOOL,
|
||||
"embedded_workflow_as_tool",
|
||||
"source-workflow-tool-id",
|
||||
"target-workflow-tool-id",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_workflow_tool_skip_records_id_mapping(monkeypatch):
|
||||
account = type("Account", (), {"id": "account-1"})()
|
||||
existing_provider = type("WorkflowToolProvider", (), {"id": "existing-workflow-tool-id"})()
|
||||
id_mapping = {"source-app-id": "target-app-id"}
|
||||
|
||||
class StubSession:
|
||||
def get(self, model, identifier):
|
||||
return account
|
||||
|
||||
class SkipImportService(MigrationImportService):
|
||||
def _find_existing_app(self, app_id, tenant_id):
|
||||
return object()
|
||||
|
||||
def _find_existing_workflow_tool(self, tenant_id, workflow_tool_id, tool_name, app_id):
|
||||
return existing_provider
|
||||
|
||||
def _ensure_workflow_app_is_published(self, target, account, app_id):
|
||||
return None
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
SkipImportService()._import_workflow_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"workflow_tools": [
|
||||
{
|
||||
"id": "source-workflow-tool-id",
|
||||
"name": "embedded_workflow_as_tool",
|
||||
"app_id": "source-app-id",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(conflict_strategy=ConflictStrategy.SKIP, id_strategy=IdStrategy.GENERATE_NEW_ID),
|
||||
id_mapping,
|
||||
[],
|
||||
[],
|
||||
)
|
||||
|
||||
assert id_mapping["source-workflow-tool-id"] == "existing-workflow-tool-id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conflict_strategy", [ConflictStrategy.SKIP, ConflictStrategy.UPDATE])
|
||||
def test_api_tool_existing_provider_records_id_mapping(monkeypatch, conflict_strategy):
|
||||
target_provider = type("ApiToolProvider", (), {"id": "target-api-provider-id", "name": "weather"})()
|
||||
id_mapping = {}
|
||||
id_mapping_details = []
|
||||
report_items = []
|
||||
|
||||
class ExistingApiImportService(MigrationImportService):
|
||||
def _find_api_tool_provider(self, tenant_id, provider_name):
|
||||
return target_provider
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: target_provider)
|
||||
monkeypatch.setattr(
|
||||
import_service.ApiToolManageService, "parser_api_schema", lambda schema: {"schema_type": "openapi"}
|
||||
)
|
||||
monkeypatch.setattr(import_service.ApiToolManageService, "update_api_tool_provider", lambda **kwargs: None)
|
||||
|
||||
ExistingApiImportService()._import_api_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"tools": [{"id": "source-api-provider-id", "provider_name": "weather", "schema": "openapi: 3.0.0"}],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(conflict_strategy=conflict_strategy),
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
{"weather": {"source-api-provider-id-from-dsl"}},
|
||||
)
|
||||
|
||||
assert id_mapping == {
|
||||
"source-api-provider-id": "target-api-provider-id",
|
||||
"source-api-provider-id-from-dsl": "target-api-provider-id",
|
||||
}
|
||||
assert (
|
||||
ResourceIdMapping(ResourceType.API_TOOL, "weather", "source-api-provider-id", "target-api-provider-id")
|
||||
in id_mapping_details
|
||||
)
|
||||
|
||||
|
||||
def test_api_tool_create_records_id_mapping(monkeypatch):
|
||||
target_provider = type("ApiToolProvider", (), {"id": "target-api-provider-id", "name": "weather"})()
|
||||
id_mapping = {}
|
||||
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
return None
|
||||
|
||||
class CreatedApiImportService(MigrationImportService):
|
||||
def _find_api_tool_provider(self, tenant_id, provider_name):
|
||||
return target_provider
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(
|
||||
import_service.ApiToolManageService, "parser_api_schema", lambda schema: {"schema_type": "openapi"}
|
||||
)
|
||||
monkeypatch.setattr(import_service.ApiToolManageService, "create_api_tool_provider", lambda **kwargs: None)
|
||||
|
||||
CreatedApiImportService()._import_api_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"tools": [{"id": "source-api-provider-id", "provider_name": "weather", "schema": "openapi: 3.0.0"}],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(),
|
||||
[],
|
||||
id_mapping,
|
||||
[],
|
||||
{},
|
||||
)
|
||||
|
||||
assert id_mapping["source-api-provider-id"] == "target-api-provider-id"
|
||||
|
||||
|
||||
def test_mcp_tool_import_restores_exported_tool_list(monkeypatch):
|
||||
provider = type("Provider", (), {"id": "target-provider-id", "tools": "[]", "authed": False})()
|
||||
report_items = []
|
||||
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
return provider
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
class StubMCPToolManageService:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def update_provider(self, **kwargs):
|
||||
return None
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
|
||||
|
||||
MigrationImportService()._import_mcp_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"mcp_tools": [
|
||||
{
|
||||
"id": "source-provider-id",
|
||||
"name": "my-test-mcp",
|
||||
"server_identifier": "my-test-mcp",
|
||||
"server_url": "http://localhost:3000/mcp",
|
||||
"configuration": {},
|
||||
"tools": [{"name": "echo"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(conflict_strategy=ConflictStrategy.UPDATE),
|
||||
report_items,
|
||||
{},
|
||||
[],
|
||||
)
|
||||
|
||||
assert provider.tools == '[{"name": "echo"}]'
|
||||
assert provider.authed is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conflict_strategy", [ConflictStrategy.SKIP, ConflictStrategy.UPDATE])
|
||||
def test_mcp_tool_existing_provider_records_id_mapping(monkeypatch, conflict_strategy):
|
||||
provider = type("Provider", (), {"id": "target-mcp-provider-id", "tools": "[]", "authed": False})()
|
||||
id_mapping = {}
|
||||
id_mapping_details = []
|
||||
|
||||
class StubSession:
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
class ExistingMCPImportService(MigrationImportService):
|
||||
def _find_existing_mcp_tool(self, tenant_id, provider_id, server_identifier):
|
||||
return provider
|
||||
|
||||
class StubMCPToolManageService:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def update_provider(self, **kwargs):
|
||||
return None
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
|
||||
|
||||
ExistingMCPImportService()._import_mcp_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"mcp_tools": [
|
||||
{
|
||||
"id": "source-mcp-provider-id",
|
||||
"name": "my-test-mcp",
|
||||
"server_identifier": "my-test-mcp",
|
||||
"server_url": "http://localhost:3000/mcp",
|
||||
"configuration": {},
|
||||
"tools": [{"name": "echo"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(conflict_strategy=conflict_strategy),
|
||||
[],
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
)
|
||||
|
||||
assert id_mapping["source-mcp-provider-id"] == "target-mcp-provider-id"
|
||||
assert "my-test-mcp" not in id_mapping
|
||||
assert id_mapping_details == [
|
||||
ResourceIdMapping(ResourceType.MCP_TOOL, "my-test-mcp", "source-mcp-provider-id", "target-mcp-provider-id")
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_tool_create_records_id_mapping(monkeypatch):
|
||||
provider = type("Provider", (), {"id": "target-mcp-provider-id", "tools": "[]", "authed": False})()
|
||||
id_mapping = {}
|
||||
provider_created = False
|
||||
|
||||
class StubSession:
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
class CreatedMCPImportService(MigrationImportService):
|
||||
def _find_existing_mcp_tool(self, tenant_id, provider_id, server_identifier):
|
||||
return provider if provider_created else None
|
||||
|
||||
class StubMCPToolManageService:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def create_provider(self, **kwargs):
|
||||
nonlocal provider_created
|
||||
provider_created = True
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
monkeypatch.setattr(import_service, "MCPToolManageService", StubMCPToolManageService)
|
||||
|
||||
CreatedMCPImportService()._import_mcp_tools(
|
||||
MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"mcp_tools": [
|
||||
{
|
||||
"id": "source-mcp-provider-id",
|
||||
"name": "my-test-mcp",
|
||||
"server_identifier": "my-test-mcp",
|
||||
"server_url": "http://localhost:3000/mcp",
|
||||
"configuration": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
ImportOptions(),
|
||||
[],
|
||||
id_mapping,
|
||||
[],
|
||||
)
|
||||
|
||||
assert id_mapping["source-mcp-provider-id"] == "target-mcp-provider-id"
|
||||
|
||||
|
||||
def test_dependency_only_mcp_preflight_reports_missing_target_provider_with_workflow_context(monkeypatch):
|
||||
report_items = []
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"dependencies": [
|
||||
{
|
||||
"kind": "mcp_tool",
|
||||
"provider_id": "my-test-mcp-server",
|
||||
"provider_name": "my-test-mcp",
|
||||
}
|
||||
],
|
||||
"workflows": [
|
||||
{
|
||||
"name": "workflow2",
|
||||
"dsl": yaml.safe_dump(
|
||||
{
|
||||
"workflow": {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-1",
|
||||
"data": {
|
||||
"type": "tool",
|
||||
"provider_type": "mcp",
|
||||
"provider_id": "my-test-mcp-server",
|
||||
"tool_name": "echo",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: None)
|
||||
|
||||
MigrationImportService()._preflight_dependency_only_mcp(
|
||||
package,
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
report_items,
|
||||
)
|
||||
|
||||
assert report_items == [
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
"my-test-mcp-server",
|
||||
"mcp_tool my-test-mcp",
|
||||
"skipped",
|
||||
"missing in target tenant; referenced by workflow2 / echo; "
|
||||
"configure it manually before running the workflow.",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_dependency_only_mcp_lookup_does_not_compare_non_uuid_identifier_to_uuid_id(monkeypatch):
|
||||
captured = []
|
||||
|
||||
class StubSession:
|
||||
def scalar(self, statement):
|
||||
captured.append(statement)
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db, "session", StubSession())
|
||||
|
||||
MigrationImportService()._find_dependency_only_mcp_provider(
|
||||
"tenant-1",
|
||||
"my-test-mcp-server",
|
||||
"my-test-mcp",
|
||||
)
|
||||
|
||||
where_clause = str(captured[0].whereclause)
|
||||
assert f"{MCPToolProvider.__tablename__}.id" not in where_clause
|
||||
|
||||
|
||||
def test_dependency_only_mcp_preflight_reports_available_target_provider(monkeypatch):
|
||||
report_items = []
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"dependencies": [{"kind": "mcp_tool", "provider_id": "my-test-mcp-server"}],
|
||||
}
|
||||
)
|
||||
provider = type(
|
||||
"Provider",
|
||||
(),
|
||||
{"id": "target-provider-id", "name": "my-test-mcp", "server_identifier": "my-test-mcp-server"},
|
||||
)()
|
||||
|
||||
from services.data_migration import import_service
|
||||
|
||||
monkeypatch.setattr(import_service.db.session, "scalar", lambda statement: provider)
|
||||
|
||||
MigrationImportService()._preflight_dependency_only_mcp(
|
||||
package,
|
||||
ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
),
|
||||
report_items,
|
||||
)
|
||||
|
||||
assert report_items == [
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
"my-test-mcp-server",
|
||||
"mcp_tool my-test-mcp",
|
||||
"available",
|
||||
"MCP provider exists in target tenant.",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_import_package_imports_workflow_tool_provider_apps_before_consumers():
|
||||
events = []
|
||||
|
||||
class StubResolver(ImportTargetResolver):
|
||||
def resolve(self, request):
|
||||
return ImportTarget(
|
||||
tenant_id="tenant-1",
|
||||
tenant_name="target",
|
||||
operator_id="account-1",
|
||||
operator_email="owner@example.com",
|
||||
)
|
||||
|
||||
class OrderedImportService(MigrationImportService):
|
||||
def _import_api_tools(
|
||||
self,
|
||||
package,
|
||||
target,
|
||||
options,
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
source_provider_ids_by_name,
|
||||
):
|
||||
events.append(("api_tools", "imported"))
|
||||
|
||||
def _import_workflows(
|
||||
self,
|
||||
package,
|
||||
target,
|
||||
options,
|
||||
report_items,
|
||||
id_mapping,
|
||||
id_mapping_details,
|
||||
*,
|
||||
imported_workflow_ids=None,
|
||||
only_app_ids=None,
|
||||
skip_app_ids=None,
|
||||
):
|
||||
only_app_ids = set(only_app_ids or [])
|
||||
skip_app_ids = set(skip_app_ids or [])
|
||||
for workflow_data in package.workflows:
|
||||
app_id = workflow_data["id"]
|
||||
if only_app_ids and app_id not in only_app_ids:
|
||||
continue
|
||||
if app_id in skip_app_ids:
|
||||
continue
|
||||
events.append(("workflow", app_id))
|
||||
id_mapping[app_id] = app_id
|
||||
if imported_workflow_ids is not None:
|
||||
imported_workflow_ids.add(app_id)
|
||||
|
||||
def _import_workflow_tools(self, package, target, options, id_mapping, id_mapping_details, report_items):
|
||||
events.append(("workflow_tool", package.workflow_tools[0]["id"]))
|
||||
|
||||
def _import_mcp_tools(self, package, target, options, report_items, id_mapping, id_mapping_details):
|
||||
events.append(("mcp_tools", "imported"))
|
||||
|
||||
package = MigrationPackage.from_mapping(
|
||||
{
|
||||
"metadata": {"version": "1", "source_scope": "single"},
|
||||
"workflows": [
|
||||
{"id": "provider-app", "name": "embedded", "dsl": "app:\n mode: workflow\n"},
|
||||
{"id": "consumer-app", "name": "main", "dsl": "app:\n mode: advanced-chat\n"},
|
||||
],
|
||||
"workflow_tools": [{"id": "workflow-tool", "name": "embedded_tool", "app_id": "provider-app"}],
|
||||
}
|
||||
)
|
||||
|
||||
OrderedImportService(target_resolver=StubResolver()).import_package(ImportRequest(package=package))
|
||||
|
||||
assert events == [
|
||||
("api_tools", "imported"),
|
||||
("mcp_tools", "imported"),
|
||||
("workflow", "provider-app"),
|
||||
("workflow_tool", "workflow-tool"),
|
||||
("workflow", "consumer-app"),
|
||||
]
|
||||
@ -1,53 +0,0 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from services.data_migration.entities import MigrationDataError
|
||||
from services.data_migration.package_service import MigrationPackageService
|
||||
|
||||
|
||||
def test_load_package_rejects_branch_draft_shape(tmp_path):
|
||||
package_file = tmp_path / "draft.json"
|
||||
package_file.write_text(json.dumps({"workflows": [], "tools": []}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(MigrationDataError, match="metadata.version"):
|
||||
MigrationPackageService().load_package(package_file)
|
||||
|
||||
|
||||
def test_load_package_rejects_unsupported_version(tmp_path):
|
||||
package_file = tmp_path / "future.json"
|
||||
package_file.write_text(json.dumps({"metadata": {"version": "999", "source_scope": "single"}}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(MigrationDataError, match="Unsupported migration package version"):
|
||||
MigrationPackageService().load_package(package_file)
|
||||
|
||||
|
||||
def test_save_package_writes_versioned_shape(tmp_path):
|
||||
output_file = tmp_path / "migration-data.json"
|
||||
package = MigrationPackageService().build_empty_package(
|
||||
source_tenant_id="tenant-1",
|
||||
source_tenant_name="source",
|
||||
include_secrets=False,
|
||||
)
|
||||
|
||||
MigrationPackageService().save_package(package, output_file, overwrite=False)
|
||||
|
||||
data = json.loads(output_file.read_text(encoding="utf-8"))
|
||||
assert data["metadata"]["version"] == "1"
|
||||
assert data["metadata"]["source_tenants"] == [{"id": "tenant-1", "name": "source"}]
|
||||
assert data["metadata"]["include_secrets"] is False
|
||||
assert data["workflows"] == []
|
||||
assert data["dependencies"] == []
|
||||
|
||||
|
||||
def test_save_package_without_overwrite_fails_when_file_exists(tmp_path):
|
||||
output_file = tmp_path / "migration-data.json"
|
||||
output_file.write_text("{}", encoding="utf-8")
|
||||
package = MigrationPackageService().build_empty_package(
|
||||
source_tenant_id="tenant-1",
|
||||
source_tenant_name="source",
|
||||
include_secrets=False,
|
||||
)
|
||||
|
||||
with pytest.raises(MigrationDataError, match="already exists"):
|
||||
MigrationPackageService().save_package(package, output_file, overwrite=False)
|
||||
@ -1,111 +0,0 @@
|
||||
from services.data_migration.entities import ReportContext, ResourceIdMapping, ResourceReportItem, ResourceType
|
||||
from services.data_migration.report_service import MigrationReportService
|
||||
|
||||
|
||||
def test_report_summarizes_by_resource_type_and_status():
|
||||
lines = MigrationReportService().render(
|
||||
[
|
||||
ResourceReportItem(ResourceType.WORKFLOW, "app-1", "App", "exported"),
|
||||
ResourceReportItem(ResourceType.API_TOOL, "weather", "Weather", "exported"),
|
||||
ResourceReportItem(ResourceType.DEPENDENCY, "mcp-1", "MCP", "dependency-only"),
|
||||
]
|
||||
)
|
||||
|
||||
assert "workflow exported: 1" in lines
|
||||
assert "api_tool exported: 1" in lines
|
||||
assert "dependency dependency-only: 1" in lines
|
||||
|
||||
|
||||
def test_report_includes_actionable_lines_for_skipped_and_unresolved_items():
|
||||
lines = MigrationReportService().render(
|
||||
[
|
||||
ResourceReportItem(ResourceType.WORKFLOW, "app-1", "App", "skipped", "App already exists"),
|
||||
ResourceReportItem(ResourceType.DEPENDENCY, "mcp-1", None, "unresolved", "MCP provider not found"),
|
||||
ResourceReportItem(ResourceType.API_TOOL, "weather", "Weather", "exported"),
|
||||
]
|
||||
)
|
||||
|
||||
assert "workflow skipped: 1" in lines
|
||||
assert "dependency unresolved: 1" in lines
|
||||
assert "workflow app-1: App already exists" in lines
|
||||
assert "dependency mcp-1: MCP provider not found" in lines
|
||||
|
||||
|
||||
def test_report_dependency_detail_uses_type_and_name_when_available():
|
||||
lines = MigrationReportService().render(
|
||||
[
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
"785e52f1-06bf-483c-8dcf-712e59fd43b9",
|
||||
"workflow embedded_workflow",
|
||||
"dependency-only",
|
||||
"Dependency metadata only; ensure the resource exists in the target environment.",
|
||||
),
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
"my-test-mcp",
|
||||
"mcp_tool my-test-mcp",
|
||||
"dependency-only",
|
||||
"Configure MCP provider manually in the target tenant unless exporting with secrets enabled.",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert (
|
||||
"dependency workflow embedded_workflow: 785e52f1-06bf-483c-8dcf-712e59fd43b9: "
|
||||
"Dependency metadata only; ensure the resource exists in the target environment."
|
||||
) in lines
|
||||
assert (
|
||||
"dependency mcp_tool my-test-mcp: "
|
||||
"Configure MCP provider manually in the target tenant unless exporting with secrets enabled."
|
||||
) in lines
|
||||
|
||||
|
||||
def test_report_includes_dependency_only_detail_lines():
|
||||
lines = MigrationReportService().render(
|
||||
[
|
||||
ResourceReportItem(
|
||||
ResourceType.DEPENDENCY,
|
||||
"mcp-1",
|
||||
"MCP",
|
||||
"dependency-only",
|
||||
"Configure manually in target tenant.",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert "dependency dependency-only: 1" in lines
|
||||
assert "dependency mcp-1: Configure manually in target tenant." in lines
|
||||
|
||||
|
||||
def test_report_includes_export_and_import_context():
|
||||
lines = MigrationReportService().render(
|
||||
[],
|
||||
context=ReportContext(
|
||||
output_path="migration-data.json",
|
||||
source_scope="single",
|
||||
selected_app_count=2,
|
||||
include_secrets=False,
|
||||
target_tenant="prod",
|
||||
operator_email="admin@example.com",
|
||||
app_api_tokens_created=1,
|
||||
app_api_tokens_reused=2,
|
||||
id_mapping_count=3,
|
||||
id_mappings={"source-app": "target-app", "source-tool": "target-tool"},
|
||||
id_mapping_details=[
|
||||
ResourceIdMapping(ResourceType.WORKFLOW, "Main workflow", "source-app", "target-app"),
|
||||
ResourceIdMapping(ResourceType.API_TOOL, "weather", "source-tool", "target-tool"),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
assert "output: migration-data.json" in lines
|
||||
assert "source scope: single" in lines
|
||||
assert "selected apps: 2" in lines
|
||||
assert "include secrets: false" in lines
|
||||
assert "target tenant: prod" in lines
|
||||
assert "operator: admin@example.com" in lines
|
||||
assert "app api tokens: 1 created, 2 reused" in lines
|
||||
assert "resource references resolved: 2" in lines
|
||||
assert "- workflow Main workflow: source-app -> target-app" in lines
|
||||
assert "- api_tool weather: source-tool -> target-tool" in lines
|
||||
@ -6,8 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../test/fixtures/dify-mock/server.js'
|
||||
import { loadAppInfoCache } from '../cache/app-info.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { ENV_CACHE_DIR } from '../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
|
||||
import { YamlStore } from '../store/store.js'
|
||||
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
|
||||
import { AppMetaClient } from './app-meta.js'
|
||||
import { AppsClient } from './apps.js'
|
||||
@ -15,24 +15,17 @@ import { AppsClient } from './apps.js'
|
||||
describe('AppMetaClient', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cache miss → fetch → populate; warm hit skips network', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
@ -47,7 +40,7 @@ describe('AppMetaClient', () => {
|
||||
})
|
||||
|
||||
it('slim hit + full request triggers fresh fetch + merges', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
@ -61,7 +54,7 @@ describe('AppMetaClient', () => {
|
||||
})
|
||||
|
||||
it('expired cache entry refetches', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
|
||||
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') })
|
||||
@ -75,7 +68,7 @@ describe('AppMetaClient', () => {
|
||||
})
|
||||
|
||||
it('invalidate forces next get to fetch', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const apps = new AppsClient(createClient({ host: mock.url, bearer: 'dfoa_test' }))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
101
cli/src/auth/file-backend.test.ts
Normal file
101
cli/src/auth/file-backend.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { FILE_PERM } from '../store/dir.js'
|
||||
import { FileBackend, TOKENS_FILE_NAME } from './file-backend.js'
|
||||
|
||||
describe('FileBackend', () => {
|
||||
let dir: string
|
||||
let backend: FileBackend
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-tokens-'))
|
||||
backend = new FileBackend(dir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns undefined when file is missing', async () => {
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns empty list when file is missing', async () => {
|
||||
expect(await backend.list('cloud.dify.ai')).toEqual([])
|
||||
})
|
||||
|
||||
it('round-trips put/get for a single token', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_abc')
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_abc')
|
||||
})
|
||||
|
||||
it('list returns accountIds for the given host', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
await backend.put('cloud.dify.ai', 'acct-2', 'dfoa_b')
|
||||
await backend.put('self.example.com', 'acct-3', 'dfoa_c')
|
||||
const ids = await backend.list('cloud.dify.ai')
|
||||
expect([...ids].sort()).toEqual(['acct-1', 'acct-2'])
|
||||
})
|
||||
|
||||
it('list returns empty array for unknown host', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
expect(await backend.list('other.example.com')).toEqual([])
|
||||
})
|
||||
|
||||
it('delete removes the entry', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
await backend.delete('cloud.dify.ai', 'acct-1')
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delete is a no-op for missing entries', async () => {
|
||||
await expect(backend.delete('cloud.dify.ai', 'missing')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('delete prunes empty host entries', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
await backend.delete('cloud.dify.ai', 'acct-1')
|
||||
expect(await backend.list('cloud.dify.ai')).toEqual([])
|
||||
})
|
||||
|
||||
it('overwrites existing token for same host+accountId', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_old')
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_new')
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_new')
|
||||
})
|
||||
|
||||
it('writes file with mode 0600', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
const info = await stat(join(dir, TOKENS_FILE_NAME))
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('rewrites existing file with mode 0600 even if previously permissive', async () => {
|
||||
const path = join(dir, TOKENS_FILE_NAME)
|
||||
await writeFile(path, 'hosts: {}\n', { mode: 0o644 })
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
const info = await stat(path)
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('writes valid YAML readable by a fresh backend', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
const fresh = new FileBackend(dir)
|
||||
expect(await fresh.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
|
||||
})
|
||||
|
||||
it('persists multiple hosts simultaneously', async () => {
|
||||
await backend.put('cloud.dify.ai', 'acct-1', 'dfoa_a')
|
||||
await backend.put('self.example.com', 'acct-2', 'dfoa_b')
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_a')
|
||||
expect(await backend.get('self.example.com', 'acct-2')).toBe('dfoa_b')
|
||||
})
|
||||
|
||||
it('treats malformed YAML as empty', async () => {
|
||||
const path = join(dir, TOKENS_FILE_NAME)
|
||||
await writeFile(path, 'not: valid: yaml: [\n', { mode: FILE_PERM })
|
||||
expect(await backend.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
99
cli/src/auth/file-backend.ts
Normal file
99
cli/src/auth/file-backend.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import type { TokenStore } from './store.js'
|
||||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
|
||||
|
||||
export const TOKENS_FILE_NAME = 'tokens.yml'
|
||||
|
||||
type AccountMap = Record<string, string>
|
||||
type HostMap = Record<string, AccountMap>
|
||||
type TokensFile = { hosts?: HostMap }
|
||||
|
||||
export class FileBackend implements TokenStore {
|
||||
private readonly dir: string
|
||||
private readonly path: string
|
||||
|
||||
constructor(dir: string) {
|
||||
this.dir = dir
|
||||
this.path = join(dir, TOKENS_FILE_NAME)
|
||||
}
|
||||
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
const file = await this.read()
|
||||
const hosts = file.hosts ?? {}
|
||||
const accounts = hosts[host] ?? {}
|
||||
accounts[accountId] = token
|
||||
hosts[host] = accounts
|
||||
await this.write({ hosts })
|
||||
}
|
||||
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
const file = await this.read()
|
||||
return file.hosts?.[host]?.[accountId]
|
||||
}
|
||||
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
const file = await this.read()
|
||||
const accounts = file.hosts?.[host]
|
||||
if (accounts === undefined || !(accountId in accounts))
|
||||
return
|
||||
delete accounts[accountId]
|
||||
if (Object.keys(accounts).length === 0 && file.hosts !== undefined)
|
||||
delete file.hosts[host]
|
||||
await this.write(file)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const file = await this.read()
|
||||
const accounts = file.hosts?.[host]
|
||||
return accounts === undefined ? [] : Object.keys(accounts)
|
||||
}
|
||||
|
||||
private async read(): Promise<TokensFile> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(this.path, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
||||
return {}
|
||||
throw err
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(raw)
|
||||
}
|
||||
catch {
|
||||
return {}
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object')
|
||||
return {}
|
||||
return parsed as TokensFile
|
||||
}
|
||||
|
||||
private async write(file: TokensFile): Promise<void> {
|
||||
await mkdir(this.dir, { recursive: true, mode: DIR_PERM })
|
||||
const body = yaml.dump(file, { lineWidth: -1, noRefs: true })
|
||||
const tmp = `${this.path}.tmp.${process.pid}.${Date.now()}`
|
||||
try {
|
||||
await writeFile(tmp, body, { mode: FILE_PERM })
|
||||
await rename(tmp, this.path)
|
||||
}
|
||||
catch (err) {
|
||||
try {
|
||||
await unlink(tmp)
|
||||
}
|
||||
catch { /* tmp may not exist */ }
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
const info = await stat(this.path)
|
||||
if ((info.mode & 0o777) !== FILE_PERM) {
|
||||
const { chmod } = await import('node:fs/promises')
|
||||
await chmod(this.path, FILE_PERM)
|
||||
}
|
||||
}
|
||||
catch { /* best-effort permission tighten */ }
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
import { FILE_PERM } from '../store/dir.js'
|
||||
import { HOSTS_FILE_NAME, HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
|
||||
describe('HostsBundleSchema', () => {
|
||||
it('parses a minimal logged-out bundle', () => {
|
||||
@ -46,86 +46,86 @@ describe('HostsBundleSchema', () => {
|
||||
})
|
||||
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('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns undefined when nothing was saved', () => {
|
||||
expect(loadHosts()).toBeUndefined()
|
||||
it('returns undefined when file is missing', async () => {
|
||||
expect(await loadHosts(dir)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips a fully-populated bundle', () => {
|
||||
saveHosts({
|
||||
it('round-trips bundle through YAML', async () => {
|
||||
await saveHosts(dir, {
|
||||
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()
|
||||
const loaded = await loadHosts(dir)
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.scheme).toBe('https')
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.workspace?.id).toBe('ws-1')
|
||||
expect(loaded?.available_workspaces).toHaveLength(2)
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
expect(loaded?.token_id).toBe('tok_xyz')
|
||||
})
|
||||
|
||||
it('round-trips a file-mode bundle with bearer token', () => {
|
||||
saveHosts({
|
||||
current_host: 'self.example.com',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(loaded?.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('overwrites previous bundle on save', () => {
|
||||
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
|
||||
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('new.example.com')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('rejects invalid input at save time', () => {
|
||||
expect(() => saveHosts({
|
||||
it('writes file with mode 0600', async () => {
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const info = await stat(join(dir, HOSTS_FILE_NAME))
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('rewrites permissive existing file with mode 0600', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'current_host: ""\ntoken_storage: file\n', { mode: 0o644 })
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const info = await stat(path)
|
||||
expect(info.mode & 0o777).toBe(FILE_PERM)
|
||||
})
|
||||
|
||||
it('atomic write: temp file does not survive on success', async () => {
|
||||
await saveHosts(dir, { current_host: 'cloud.dify.ai', token_storage: 'file' })
|
||||
const { readdir } = await import('node:fs/promises')
|
||||
const entries = await readdir(dir)
|
||||
expect(entries.filter(n => n.includes('.tmp.'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'current_host: cloud.dify.ai\nfuture_field: 42\ntoken_storage: file\n', { mode: FILE_PERM })
|
||||
const loaded = await loadHosts(dir)
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect((loaded as Record<string, unknown> | undefined)?.future_field).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws on malformed YAML', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, ': : :\n', { mode: FILE_PERM })
|
||||
await expect(loadHosts(dir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when YAML contradicts schema', async () => {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
await writeFile(path, 'token_storage: cloud\n', { mode: FILE_PERM })
|
||||
await expect(loadHosts(dir)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('produces YAML with stable keys', async () => {
|
||||
await saveHosts(dir, {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'cloud',
|
||||
} as never)).toThrow()
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_x' },
|
||||
})
|
||||
const raw = await readFile(join(dir, HOSTS_FILE_NAME), 'utf8')
|
||||
expect(raw).toContain('current_host: cloud.dify.ai')
|
||||
expect(raw).toContain('bearer: dfoa_x')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import type { Store } from '../store/store.js'
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { z } from 'zod'
|
||||
import { getHostStore, tokenKey } from '../store/manager.js'
|
||||
import { DIR_PERM, FILE_PERM } from '../store/dir.js'
|
||||
|
||||
export const HOSTS_FILE_NAME = 'hosts.yml'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
export type StorageMode = z.infer<typeof StorageModeSchema>
|
||||
@ -44,23 +48,53 @@ export const HostsBundleSchema = z.object({
|
||||
})
|
||||
export type HostsBundle = z.infer<typeof HostsBundleSchema>
|
||||
|
||||
export function loadHosts(): HostsBundle | undefined {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return undefined
|
||||
return HostsBundleSchema.parse(raw)
|
||||
}
|
||||
|
||||
export function saveHosts(bundle: HostsBundle): void {
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
getHostStore().setTyped(validated)
|
||||
}
|
||||
|
||||
export function clearLocal(bundle: HostsBundle, store: Store): void {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
export async function loadHosts(dir: string): Promise<HostsBundle | undefined> {
|
||||
const path = join(dir, HOSTS_FILE_NAME)
|
||||
let raw: string
|
||||
try {
|
||||
store.unset(tokenKey(bundle.current_host, accountId))
|
||||
raw = await readFile(path, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT')
|
||||
return undefined
|
||||
throw err
|
||||
}
|
||||
const parsed = yaml.load(raw)
|
||||
return HostsBundleSchema.parse(parsed ?? {})
|
||||
}
|
||||
|
||||
export async function saveHosts(dir: string, bundle: HostsBundle): Promise<void> {
|
||||
await mkdir(dir, { recursive: true, mode: DIR_PERM })
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
const body = yaml.dump(stripUndefined(validated), { lineWidth: -1, noRefs: true, sortKeys: false })
|
||||
const target = join(dir, HOSTS_FILE_NAME)
|
||||
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`
|
||||
try {
|
||||
await writeFile(tmp, body, { mode: FILE_PERM })
|
||||
await rename(tmp, target)
|
||||
}
|
||||
catch (err) {
|
||||
try {
|
||||
await unlink(tmp)
|
||||
}
|
||||
catch { /* tmp may not exist */ }
|
||||
throw err
|
||||
}
|
||||
const { chmod, stat } = await import('node:fs/promises')
|
||||
try {
|
||||
const info = await stat(target)
|
||||
if ((info.mode & 0o777) !== FILE_PERM)
|
||||
await chmod(target, FILE_PERM)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
getHostStore().rm()
|
||||
}
|
||||
|
||||
function stripUndefined<T extends Record<string, unknown>>(input: T): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v === undefined)
|
||||
continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
111
cli/src/auth/keyring-backend.test.ts
Normal file
111
cli/src/auth/keyring-backend.test.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const passwords = new Map<string, string>()
|
||||
const setPassword = vi.fn()
|
||||
const getPassword = vi.fn()
|
||||
const deletePassword = vi.fn()
|
||||
|
||||
class FakeAsyncEntry {
|
||||
private readonly key: string
|
||||
constructor(service: string, username: string) {
|
||||
this.key = `${service}::${username}`
|
||||
}
|
||||
|
||||
async setPassword(value: string): Promise<void> {
|
||||
setPassword(this.key, value)
|
||||
passwords.set(this.key, value)
|
||||
}
|
||||
|
||||
async getPassword(): Promise<string | undefined> {
|
||||
getPassword(this.key)
|
||||
return passwords.get(this.key)
|
||||
}
|
||||
|
||||
async deletePassword(): Promise<boolean> {
|
||||
deletePassword(this.key)
|
||||
if (!passwords.has(this.key))
|
||||
return false
|
||||
passwords.delete(this.key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@napi-rs/keyring', () => ({
|
||||
AsyncEntry: FakeAsyncEntry,
|
||||
}))
|
||||
|
||||
const { KEYRING_SERVICE, KeyringBackend } = await import('./keyring-backend.js')
|
||||
|
||||
beforeEach(() => {
|
||||
passwords.clear()
|
||||
setPassword.mockClear()
|
||||
getPassword.mockClear()
|
||||
deletePassword.mockClear()
|
||||
})
|
||||
|
||||
describe('KeyringBackend', () => {
|
||||
it('uses service name "difyctl"', () => {
|
||||
expect(KEYRING_SERVICE).toBe('difyctl')
|
||||
})
|
||||
|
||||
it('returns undefined when no password is stored', async () => {
|
||||
const k = new KeyringBackend()
|
||||
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips put/get', async () => {
|
||||
const k = new KeyringBackend()
|
||||
await k.put('cloud.dify.ai', 'acct-1', 'dfoa_x')
|
||||
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('dfoa_x')
|
||||
})
|
||||
|
||||
it('keys by host::accountId', async () => {
|
||||
const k = new KeyringBackend()
|
||||
await k.put('cloud.dify.ai', 'acct-1', 'A')
|
||||
await k.put('cloud.dify.ai', 'acct-2', 'B')
|
||||
expect(await k.get('cloud.dify.ai', 'acct-1')).toBe('A')
|
||||
expect(await k.get('cloud.dify.ai', 'acct-2')).toBe('B')
|
||||
})
|
||||
|
||||
it('delete removes the entry', async () => {
|
||||
const k = new KeyringBackend()
|
||||
await k.put('cloud.dify.ai', 'acct-1', 'A')
|
||||
await k.delete('cloud.dify.ai', 'acct-1')
|
||||
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('delete is a no-op for missing entries', async () => {
|
||||
const k = new KeyringBackend()
|
||||
await expect(k.delete('cloud.dify.ai', 'gone')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('list returns empty array (keyring does not enumerate)', async () => {
|
||||
const k = new KeyringBackend()
|
||||
await k.put('cloud.dify.ai', 'acct-1', 'A')
|
||||
expect(await k.list('cloud.dify.ai')).toEqual([])
|
||||
})
|
||||
|
||||
it('swallows getPassword exceptions and returns undefined', async () => {
|
||||
const k = new KeyringBackend()
|
||||
getPassword.mockImplementationOnce(() => {
|
||||
throw new Error('NoEntry')
|
||||
})
|
||||
expect(await k.get('cloud.dify.ai', 'acct-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('swallows delete exceptions', async () => {
|
||||
const k = new KeyringBackend()
|
||||
deletePassword.mockImplementationOnce(() => {
|
||||
throw new Error('NoEntry')
|
||||
})
|
||||
await expect(k.delete('cloud.dify.ai', 'acct-1')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('lets put propagate exceptions (caller decides fallback)', async () => {
|
||||
const k = new KeyringBackend()
|
||||
setPassword.mockImplementationOnce(() => {
|
||||
throw new Error('keyring locked')
|
||||
})
|
||||
await expect(k.put('cloud.dify.ai', 'acct-1', 'tok')).rejects.toThrow(/keyring locked/)
|
||||
})
|
||||
})
|
||||
35
cli/src/auth/keyring-backend.ts
Normal file
35
cli/src/auth/keyring-backend.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { TokenStore } from './store.js'
|
||||
import { AsyncEntry } from '@napi-rs/keyring'
|
||||
|
||||
export const KEYRING_SERVICE = 'difyctl'
|
||||
|
||||
function username(host: string, accountId: string): string {
|
||||
return `${host}::${accountId}`
|
||||
}
|
||||
|
||||
export class KeyringBackend implements TokenStore {
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).setPassword(token)
|
||||
}
|
||||
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const v = await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).getPassword()
|
||||
return v ?? undefined
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
try {
|
||||
await new AsyncEntry(KEYRING_SERVICE, username(host, accountId)).deletePassword()
|
||||
}
|
||||
catch { /* missing entry is fine */ }
|
||||
}
|
||||
|
||||
async list(_host: string): Promise<readonly string[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
75
cli/src/auth/store.test.ts
Normal file
75
cli/src/auth/store.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import type { TokenStore } from './store.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { selectStore } from './store.js'
|
||||
|
||||
function memBackend(label: string): TokenStore & { _label: string } {
|
||||
const map = new Map<string, string>()
|
||||
const k = (h: string, a: string) => `${h}::${a}`
|
||||
return {
|
||||
_label: label,
|
||||
async put(h, a, t) { map.set(k(h, a), t) },
|
||||
async get(h, a) { return map.get(k(h, a)) },
|
||||
async delete(h, a) { map.delete(k(h, a)) },
|
||||
async list() { return [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectStore', () => {
|
||||
it('returns keychain when probe succeeds', async () => {
|
||||
const k = memBackend('keyring')
|
||||
const f = memBackend('file')
|
||||
const result = await selectStore({
|
||||
configDir: '/tmp/x',
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('keychain')
|
||||
expect(result.store).toBe(k)
|
||||
})
|
||||
|
||||
it('falls back to file when keyring put throws', async () => {
|
||||
const k = memBackend('keyring')
|
||||
const f = memBackend('file')
|
||||
k.put = vi.fn().mockRejectedValue(new Error('locked'))
|
||||
const result = await selectStore({
|
||||
configDir: '/tmp/x',
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('falls back to file when probe round-trip mismatches', async () => {
|
||||
const k = memBackend('keyring')
|
||||
const f = memBackend('file')
|
||||
k.get = vi.fn().mockResolvedValue('something-else')
|
||||
const result = await selectStore({
|
||||
configDir: '/tmp/x',
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('falls back to file when keyring constructor throws', async () => {
|
||||
const f = memBackend('file')
|
||||
const result = await selectStore({
|
||||
configDir: '/tmp/x',
|
||||
factory: {
|
||||
keyring: () => { throw new Error('no backend') },
|
||||
file: () => f,
|
||||
},
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('cleans up probe entry after successful probe', async () => {
|
||||
const k = memBackend('keyring')
|
||||
const f = memBackend('file')
|
||||
await selectStore({
|
||||
configDir: '/tmp/x',
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(await k.get('__difyctl_probe__', '__probe__')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
40
cli/src/auth/store.ts
Normal file
40
cli/src/auth/store.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { FileBackend } from './file-backend.js'
|
||||
import { KeyringBackend } from './keyring-backend.js'
|
||||
|
||||
export type TokenStore = {
|
||||
put: (host: string, accountId: string, token: string) => Promise<void>
|
||||
get: (host: string, accountId: string) => Promise<string | undefined>
|
||||
delete: (host: string, accountId: string) => Promise<void>
|
||||
list: (host: string) => Promise<readonly string[]>
|
||||
}
|
||||
|
||||
export type StorageMode = 'keychain' | 'file'
|
||||
|
||||
export type SelectStoreOptions = {
|
||||
readonly configDir: string
|
||||
readonly factory?: {
|
||||
readonly keyring?: () => TokenStore
|
||||
readonly file?: (dir: string) => TokenStore
|
||||
}
|
||||
}
|
||||
|
||||
const PROBE_HOST = '__difyctl_probe__'
|
||||
const PROBE_ACCOUNT = '__probe__'
|
||||
const PROBE_VALUE = 'probe-v1'
|
||||
|
||||
export async function selectStore(opts: SelectStoreOptions): Promise<{ store: TokenStore, mode: StorageMode }> {
|
||||
const fileFactory = opts.factory?.file ?? ((dir: string) => new FileBackend(dir))
|
||||
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBackend())
|
||||
try {
|
||||
const k = keyringFactory()
|
||||
await k.put(PROBE_HOST, PROBE_ACCOUNT, PROBE_VALUE)
|
||||
const got = await k.get(PROBE_HOST, PROBE_ACCOUNT)
|
||||
await k.delete(PROBE_HOST, PROBE_ACCOUNT)
|
||||
if (got !== PROBE_VALUE)
|
||||
throw new Error('keyring round-trip mismatch')
|
||||
return { store: k, mode: 'keychain' }
|
||||
}
|
||||
catch {
|
||||
return { store: fileFactory(opts.configDir), mode: 'file' }
|
||||
}
|
||||
}
|
||||
31
cli/src/cache/app-info.test.ts
vendored
31
cli/src/cache/app-info.test.ts
vendored
@ -4,8 +4,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '../store/dir.js'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../store/manager.js'
|
||||
import { YamlStore } from '../store/store.js'
|
||||
import { platform } from '../sys/index.js'
|
||||
import { FieldInfo, FieldParameters } from '../types/app-meta.js'
|
||||
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info.js'
|
||||
@ -35,25 +35,18 @@ function metaInfoOnly(): AppMeta {
|
||||
|
||||
describe('app-info disk cache', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips an entry across reloads', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const got = c2.get('http://localhost:9999', 'app-1')
|
||||
expect(got).toBeDefined()
|
||||
expect(got?.meta.info?.id).toBe('app-1')
|
||||
@ -62,7 +55,7 @@ describe('app-info disk cache', () => {
|
||||
|
||||
it('isFresh respects TTL', async () => {
|
||||
const now = new Date('2026-05-09T00:00:00Z')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)), now: () => now })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const r = c.get('h', 'app-1')
|
||||
expect(r).toBeDefined()
|
||||
@ -73,23 +66,23 @@ describe('app-info disk cache', () => {
|
||||
})
|
||||
|
||||
it('keys by (host, app_id) — different hosts isolate', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await c.set('h1', 'app-1', metaInfoOnly())
|
||||
expect(c.get('h2', 'app-1')).toBeUndefined()
|
||||
expect(c.get('h1', 'app-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('delete removes entry from disk', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c1 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await c1.set('h', 'app-1', metaInfoOnly())
|
||||
await c1.delete('h', 'app-1')
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c2 = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
expect(c2.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes file with 0600 permission', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const { stat } = await import('node:fs/promises')
|
||||
const s = await stat(appInfoPath(dir))
|
||||
@ -98,19 +91,19 @@ describe('app-info disk cache', () => {
|
||||
})
|
||||
|
||||
it('missing cache file is not an error', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('corrupt cache file is treated as empty', async () => {
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates same key in place (no growth)', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const c = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const slim: AppMeta = {
|
||||
...metaInfoOnly(),
|
||||
|
||||
31
cli/src/cache/nudge-store.test.ts
vendored
31
cli/src/cache/nudge-store.test.ts
vendored
@ -3,8 +3,8 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CACHE_DIR } from '../store/dir.js'
|
||||
import { CACHE_NUDGE, cachePath, getCache } from '../store/manager.js'
|
||||
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
|
||||
import { YamlStore } from '../store/store.js'
|
||||
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store.js'
|
||||
|
||||
function nudgeStorePath(dir: string): string {
|
||||
@ -15,28 +15,21 @@ const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
describe('NudgeStore', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('canWarn=true when no prior record exists', async () => {
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('canWarn=false within the silence window, true past it', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
|
||||
@ -44,7 +37,7 @@ describe('NudgeStore', () => {
|
||||
|
||||
it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
|
||||
expect(store.canWarn(HOST, pastClock)).toBe(false)
|
||||
@ -52,22 +45,22 @@ describe('NudgeStore', () => {
|
||||
|
||||
it('markWarned persists across store reloads', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
const s1 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
|
||||
await s1.markWarned(HOST)
|
||||
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
const s2 = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t0 })
|
||||
expect(s2.canWarn(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a corrupt cache file as empty', async () => {
|
||||
const path = nudgeStorePath(dir)
|
||||
await writeCacheFile(path, '{ not valid json')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('writes ISO timestamps under warned/<host> on disk', async () => {
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
|
||||
await store.markWarned(HOST)
|
||||
const raw = await readFile(nudgeStorePath(dir), 'utf8')
|
||||
const parsed = yaml.load(raw) as Record<string, unknown>
|
||||
@ -79,11 +72,11 @@ describe('NudgeStore', () => {
|
||||
// warns about a different host. Without merge-on-write the second writer
|
||||
// would clobber the first.
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const a = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
|
||||
const b = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
|
||||
await a.markWarned('https://a.example')
|
||||
await b.markWarned('https://b.example')
|
||||
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const reread = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => t })
|
||||
expect(reread.canWarn('https://a.example')).toBe(false)
|
||||
expect(reread.canWarn('https://b.example')).toBe(false)
|
||||
})
|
||||
|
||||
@ -12,6 +12,7 @@ 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 { resolveConfigDir } from '../../store/dir.js'
|
||||
import { realStreams } from '../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../util/host.js'
|
||||
import { versionInfo } from '../../version/info.js'
|
||||
@ -23,6 +24,7 @@ export type AuthedContext = {
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
readonly configDir: string
|
||||
readonly cache?: AppInfoCache
|
||||
}
|
||||
|
||||
@ -36,8 +38,9 @@ export async function buildAuthedContext(
|
||||
cmd: Pick<Command, 'error'>,
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const configDir = resolveConfigDir()
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const bundle = loadHosts()
|
||||
const bundle = await loadHosts(configDir)
|
||||
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
const err = new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
@ -58,7 +61,7 @@ export async function buildAuthedContext(
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { bundle, http, host, io, cache }
|
||||
return { bundle, http, host, io, configDir, cache }
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -2,7 +2,7 @@ import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openap
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../../store/store.js'
|
||||
import type { TokenStore } from '../../../../auth/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -10,23 +10,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.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'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys()).filter(k => k.startsWith(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,18 +93,11 @@ describe('runDevicesList', () => {
|
||||
describe('runDevicesRevoke', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-devrevoke-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -110,11 +106,11 @@ describe('runDevicesRevoke', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
await store.put(b.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -125,7 +121,7 @@ describe('runDevicesRevoke', () => {
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
@ -135,7 +131,7 @@ describe('runDevicesRevoke', () => {
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
@ -145,7 +141,7 @@ describe('runDevicesRevoke', () => {
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -156,7 +152,7 @@ describe('runDevicesRevoke', () => {
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -167,7 +163,7 @@ describe('runDevicesRevoke', () => {
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
@ -175,20 +171,20 @@ describe('runDevicesRevoke', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
await store.put(b.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ configDir, io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
await expect(runDevicesRevoke({ configDir, io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { Store } from '../../../../store/store.js'
|
||||
import type { TokenStore } from '../../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../../sys/io/streams'
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../../auth/hosts.js'
|
||||
import { HOSTS_FILE_NAME } from '../../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
import { getTokenStore } from '../../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
|
||||
import { runWithSpinner } from '../../../../sys/io/spinner.js'
|
||||
|
||||
@ -71,11 +72,11 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
}
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly store: TokenStore
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -103,10 +104,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit) {
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(b, tokens)
|
||||
}
|
||||
if (selfHit)
|
||||
await clearLocal(opts.configDir, b, opts.store)
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
@ -179,3 +178,18 @@ function renderTable(rows: readonly SessionRow[], currentId: string): string {
|
||||
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
|
||||
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
|
||||
}
|
||||
|
||||
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
await store.delete(bundle.current_host, accountId)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
try {
|
||||
await unlink(join(configDir, HOSTS_FILE_NAME))
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { selectStore } from '../../../../auth/store.js'
|
||||
import { Args, Flags } from '../../../../framework/flags.js'
|
||||
import { DifyCommand } from '../../../_shared/dify-command.js'
|
||||
import { httpRetryFlag } from '../../../_shared/global-flags.js'
|
||||
@ -24,10 +25,13 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { args, flags } = this.parse(DevicesRevoke, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
const { store } = await selectStore({ configDir: ctx.configDir })
|
||||
await runDevicesRevoke({
|
||||
configDir: ctx.configDir,
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
store,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
yes: flags.yes,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runLogin } from './login.js'
|
||||
@ -30,6 +31,7 @@ export default class Login extends DifyCommand {
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Login, argv)
|
||||
await runLogin({
|
||||
configDir: resolveConfigDir(),
|
||||
io: realStreams(),
|
||||
host: flags.host,
|
||||
noBrowser: flags['no-browser'],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { Clock } from './device-flow.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@ -8,8 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { tokenKey } from '../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogin } from './login.js'
|
||||
|
||||
@ -20,38 +18,38 @@ const noopClock: Clock = {
|
||||
|
||||
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => k.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
|
||||
describe('runLogin', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-login-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -60,6 +58,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -74,7 +73,7 @@ describe('runLogin', () => {
|
||||
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'))
|
||||
const stored = await store.get(bundle.current_host, 'acct-1')
|
||||
expect(stored).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
@ -92,6 +91,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -115,6 +115,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -134,6 +135,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -150,6 +152,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -166,6 +169,7 @@ describe('runLogin', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await runLogin({
|
||||
configDir,
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { StorageMode, Store } from '../../../store/store.js'
|
||||
import type { HostsBundle, StorageMode, Workspace } from '../../../auth/hosts.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
|
||||
import type { Clock } from './device-flow.js'
|
||||
@ -8,20 +8,21 @@ import * as os from 'node:os'
|
||||
import * as readline from 'node:readline'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { selectStore } from '../../../auth/store.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
|
||||
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
|
||||
import { awaitAuthorization, realClock } from './device-flow.js'
|
||||
|
||||
export type LoginOptions = {
|
||||
readonly configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly host?: string
|
||||
readonly noBrowser?: boolean
|
||||
readonly insecure?: boolean
|
||||
readonly deviceLabel?: string
|
||||
readonly store?: { readonly store: Store, readonly mode: StorageMode }
|
||||
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
|
||||
readonly api?: DeviceFlowApi
|
||||
readonly browserEnv?: BrowserEnv
|
||||
readonly browserOpener?: BrowserOpener
|
||||
@ -58,11 +59,11 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const storeBundle = opts.store ?? await selectStore({ configDir: opts.configDir })
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
|
||||
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
|
||||
saveHosts(bundle)
|
||||
await storeBundle.store.put(bundle.current_host, accountKey(bundle), success.token)
|
||||
await saveHosts(opts.configDir, bundle)
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return bundle
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { selectStore } from '../../../auth/store.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../../util/host.js'
|
||||
@ -16,7 +18,9 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const bundle = loadHosts()
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
const { store } = await selectStore({ configDir })
|
||||
|
||||
let http: KyInstance | undefined
|
||||
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
|
||||
@ -30,7 +34,7 @@ export default class Logout extends DifyCommand {
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, bundle, http }),
|
||||
() => runLogout({ configDir, io, bundle, http, store }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 type { TokenStore } from '../../../auth/store.js'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -8,23 +8,28 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { 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
|
||||
class MemStore implements TokenStore {
|
||||
readonly entries = new Map<string, string>()
|
||||
async put(host: string, accountId: string, token: string): Promise<void> {
|
||||
this.entries.set(`${host}::${accountId}`, token)
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
async get(host: string, accountId: string): Promise<string | undefined> {
|
||||
return this.entries.get(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
async delete(host: string, accountId: string): Promise<void> {
|
||||
this.entries.delete(`${host}::${accountId}`)
|
||||
}
|
||||
|
||||
async list(host: string): Promise<readonly string[]> {
|
||||
const prefix = `${host}::`
|
||||
return Array.from(this.entries.keys())
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => k.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,20 +52,13 @@ function fixtureBundle(host: string): HostsBundle {
|
||||
describe('runLogout', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -69,11 +67,11 @@ describe('runLogout', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
@ -84,7 +82,7 @@ describe('runLogout', () => {
|
||||
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/)
|
||||
await expect(runLogout({ configDir, io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('hosts.yml absent: still completes locally + emits success', async () => {
|
||||
@ -93,7 +91,7 @@ describe('runLogout', () => {
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
@ -102,12 +100,12 @@ describe('runLogout', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfoa_test')
|
||||
await saveHosts(configDir, bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
expect(io.errBuf()).toContain('server revoke failed')
|
||||
@ -119,11 +117,11 @@ describe('runLogout', () => {
|
||||
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)
|
||||
await store.put(bundle.current_host, 'acct-1', 'dfp_personal_token')
|
||||
await saveHosts(configDir, bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
expect(io.errBuf()).toBe('')
|
||||
expect(store.entries.size).toBe(0)
|
||||
@ -133,11 +131,11 @@ describe('runLogout', () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
saveHosts(bundle)
|
||||
await saveHosts(configDir, bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
await runLogout({ configDir, io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { TokenStore } from '../../../auth/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { unlink } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { AccountSessionsClient } from '../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../auth/hosts.js'
|
||||
import { HOSTS_FILE_NAME } 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 configDir: string
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly store: TokenStore
|
||||
}
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
@ -39,8 +40,7 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(bundle, tokens)
|
||||
await clearLocal(opts.configDir, bundle, opts.store)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
@ -52,3 +52,19 @@ const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
async function clearLocal(configDir: string, bundle: HostsBundle, store: TokenStore): Promise<void> {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
await store.delete(bundle.current_host, accountId)
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
const hostsPath = join(configDir, HOSTS_FILE_NAME)
|
||||
try {
|
||||
await unlink(hostsPath)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runStatus } from './status.js'
|
||||
@ -20,7 +21,8 @@ export default class Status extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const bundle = loadHosts()
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runWhoami } from './whoami.js'
|
||||
@ -18,7 +19,8 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const bundle = loadHosts()
|
||||
const configDir = resolveConfigDir()
|
||||
const bundle = await loadHosts(configDir)
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,43 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { FILE_NAME } from '../../../config/schema.js'
|
||||
import { isBaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { getConfigurationStore } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runConfigGet } from './run.js'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('runConfigGet', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-get-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns set value with trailing newline', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'yaml' },
|
||||
})
|
||||
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
|
||||
it('returns set value with trailing newline', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: yaml\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
|
||||
expect(out).toBe('yaml\n')
|
||||
})
|
||||
|
||||
it('returns empty line when key is unset (matches Go fmt.Fprintln)', () => {
|
||||
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.format' })
|
||||
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.format' })
|
||||
expect(out).toBe('\n')
|
||||
})
|
||||
|
||||
it('throws BaseError(config_invalid_key) on unknown key', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
|
||||
runConfigGet({ store: makeStore(dir), key: 'bogus.key' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -51,12 +45,13 @@ describe('runConfigGet', () => {
|
||||
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
|
||||
})
|
||||
|
||||
it('returns numeric limit as string', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { limit: 75 },
|
||||
})
|
||||
const out = runConfigGet({ store: getConfigurationStore(), key: 'defaults.limit' })
|
||||
it('returns numeric limit as string', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n limit: 75\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigGet({ store: makeStore(dir), key: 'defaults.limit' })
|
||||
expect(out).toBe('75\n')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { join } from 'node:path'
|
||||
import { raw } from '../../../framework/output.js'
|
||||
import { resolveConfigDir } from '../../../store/dir.js'
|
||||
import { CONFIG_FILE_NAME } from '../../../store/manager.js'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runConfigPath } from './run.js'
|
||||
|
||||
export default class ConfigPath extends DifyCommand {
|
||||
static override description = 'Print the resolved config.yml path'
|
||||
@ -13,8 +12,6 @@ export default class ConfigPath extends DifyCommand {
|
||||
|
||||
async run(argv: string[]) {
|
||||
this.parse(ConfigPath, argv)
|
||||
return raw(
|
||||
join(resolveConfigDir(), CONFIG_FILE_NAME),
|
||||
)
|
||||
return raw(runConfigPath({ dir: resolveConfigDir() }))
|
||||
}
|
||||
}
|
||||
|
||||
14
cli/src/commands/config/path/run.test.ts
Normal file
14
cli/src/commands/config/path/run.test.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runConfigPath } from './run.js'
|
||||
|
||||
describe('runConfigPath', () => {
|
||||
it('joins dir and config.yml with trailing newline', () => {
|
||||
const out = runConfigPath({ dir: '/tmp/x' })
|
||||
expect(out).toBe('/tmp/x/config.yml\n')
|
||||
})
|
||||
|
||||
it('handles trailing slash on dir', () => {
|
||||
const out = runConfigPath({ dir: '/tmp/x/' })
|
||||
expect(out).toBe('/tmp/x/config.yml\n')
|
||||
})
|
||||
})
|
||||
10
cli/src/commands/config/path/run.ts
Normal file
10
cli/src/commands/config/path/run.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { join } from 'node:path'
|
||||
import { FILE_NAME } from '../../../config/schema.js'
|
||||
|
||||
export type RunConfigPathOptions = {
|
||||
readonly dir: string
|
||||
}
|
||||
|
||||
export function runConfigPath(opts: RunConfigPathOptions): string {
|
||||
return `${join(opts.dir, FILE_NAME)}\n`
|
||||
}
|
||||
@ -1,46 +1,35 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { loadConfig } from '../../../config/config-loader.js'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { FILE_NAME } from '../../../config/schema.js'
|
||||
import { isBaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode, ExitCode } from '../../../errors/codes.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { getConfigurationStore } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runConfigSet } from './run.js'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('runConfigSet', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-set-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('persists the value and returns "set k = v\\n"', () => {
|
||||
const out = runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
|
||||
it('writes config.yml and returns "set k = v\\n"', async () => {
|
||||
const out = runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'json' })
|
||||
expect(out).toBe('set defaults.format = json\n')
|
||||
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found)
|
||||
expect(r.config.defaults.format).toBe('json')
|
||||
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
|
||||
expect(raw).toContain('format: json')
|
||||
})
|
||||
|
||||
it('rejects invalid format value with config_invalid_value', () => {
|
||||
it('rejects invalid format value with config_invalid_value', async () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -51,7 +40,7 @@ describe('runConfigSet', () => {
|
||||
it('rejects unknown key with config_invalid_key', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -59,22 +48,18 @@ describe('runConfigSet', () => {
|
||||
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
|
||||
})
|
||||
|
||||
it('preserves prior keys when setting a new one', () => {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'yaml' })
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: '40' })
|
||||
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found) {
|
||||
expect(r.config.defaults.format).toBe('yaml')
|
||||
expect(r.config.defaults.limit).toBe(40)
|
||||
}
|
||||
it('preserves prior keys when setting a new one', async () => {
|
||||
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'yaml' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: '40' })
|
||||
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
|
||||
expect(raw).toContain('format: yaml')
|
||||
expect(raw).toContain('limit: 40')
|
||||
})
|
||||
|
||||
it('exit code for invalid value is Usage (2)', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'defaults.format', value: 'csv' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -85,7 +70,7 @@ describe('runConfigSet', () => {
|
||||
it('exit code for unknown key is Usage (2)', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'bogus', value: 'x' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -96,7 +81,7 @@ describe('runConfigSet', () => {
|
||||
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
|
||||
runConfigSet({ store: makeStore(dir), key: 'defaults.limit', value: 'abc' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
|
||||
@ -1,61 +1,48 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { loadConfig } from '../../../config/config-loader.js'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { FILE_NAME } from '../../../config/schema.js'
|
||||
import { isBaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { getConfigurationStore } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runConfigUnset } from './run.js'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('runConfigUnset', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-unset-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('clears the requested key, leaves others intact', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'json', limit: 25 },
|
||||
})
|
||||
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
|
||||
it('clears the requested key, leaves others intact', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: json\n limit: 25\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
|
||||
expect(out).toBe('unset defaults.format\n')
|
||||
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found) {
|
||||
expect(r.config.defaults.format).not.toBe('json')
|
||||
expect(r.config.defaults.limit).toBe(25)
|
||||
}
|
||||
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
|
||||
expect(raw).not.toContain('format:')
|
||||
expect(raw).toContain('limit: 25')
|
||||
})
|
||||
|
||||
it('is a no-op (writes empty config) when key was already unset', () => {
|
||||
const out = runConfigUnset({ store: getConfigurationStore(), key: 'defaults.format' })
|
||||
it('is a no-op (writes empty config) when key was already unset', async () => {
|
||||
const out = runConfigUnset({ store: makeStore(dir), key: 'defaults.format' })
|
||||
expect(out).toBe('unset defaults.format\n')
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found)
|
||||
expect(r.config.schema_version).toBe(1)
|
||||
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
|
||||
expect(raw).toContain('schema_version: 1')
|
||||
})
|
||||
|
||||
it('rejects unknown key', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
|
||||
runConfigUnset({ store: makeStore(dir), key: 'bogus' })
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
|
||||
@ -1,69 +1,67 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { getConfigurationStore } from '../../../store/manager.js'
|
||||
import { FILE_NAME } from '../../../config/schema.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runConfigView } from './run.js'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('runConfigView', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-view-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
// tmpdir cleanup is best-effort
|
||||
})
|
||||
|
||||
it('text format: empty config returns empty string', () => {
|
||||
const out = runConfigView({ store: getConfigurationStore() })
|
||||
const out = runConfigView({ store: makeStore(dir) })
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('text format: emits "key = value" lines for set keys only', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'json', limit: 50 },
|
||||
state: { current_app: 'app-1' },
|
||||
})
|
||||
const out = runConfigView({ store: getConfigurationStore() })
|
||||
it('text format: emits "key = value" lines for set keys only', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: json\n limit: 50\nstate:\n current_app: app-1\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigView({ store: makeStore(dir) })
|
||||
expect(out).toBe(
|
||||
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('text format: skips unset keys', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'yaml' },
|
||||
})
|
||||
const out = runConfigView({ store: getConfigurationStore() })
|
||||
it('text format: skips unset keys', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: yaml\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigView({ store: makeStore(dir) })
|
||||
expect(out).toBe('defaults.format = yaml\n')
|
||||
expect(out).not.toContain('defaults.limit')
|
||||
expect(out).not.toContain('state.current_app')
|
||||
})
|
||||
|
||||
it('json format: empty config returns "{}\\n"', () => {
|
||||
const out = runConfigView({ store: getConfigurationStore(), json: true })
|
||||
const out = runConfigView({ store: makeStore(dir), json: true })
|
||||
expect(out).toBe('{}\n')
|
||||
})
|
||||
|
||||
it('json format: defaults.limit is numeric, others are strings', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'table', limit: 100 },
|
||||
state: { current_app: 'app-x' },
|
||||
})
|
||||
const out = runConfigView({ store: getConfigurationStore(), json: true })
|
||||
it('json format: defaults.limit is numeric, others are strings', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: table\n limit: 100\nstate:\n current_app: app-x\n',
|
||||
'utf8',
|
||||
)
|
||||
const out = runConfigView({ store: makeStore(dir), json: true })
|
||||
const parsed = JSON.parse(out) as Record<string, unknown>
|
||||
expect(parsed['defaults.format']).toBe('table')
|
||||
expect(parsed['defaults.limit']).toBe(100)
|
||||
@ -71,7 +69,7 @@ describe('runConfigView', () => {
|
||||
})
|
||||
|
||||
it('json format: trailing newline matches Go encoder.Encode', () => {
|
||||
const out = runConfigView({ store: getConfigurationStore(), json: true })
|
||||
const out = runConfigView({ store: makeStore(dir), json: true })
|
||||
expect(out.endsWith('\n')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,8 +8,8 @@ import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { loadAppInfoCache } from '../../../cache/app-info.js'
|
||||
import { formatted, stringifyOutput } from '../../../framework/output.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
@ -29,24 +29,17 @@ function bundle(): HostsBundle {
|
||||
describe('runDescribeApp', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-desc-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
@ -89,7 +82,7 @@ describe('runDescribeApp', () => {
|
||||
})
|
||||
|
||||
it('refresh: bypasses cache', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
|
||||
@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { loadAppInfoCache } from '../../../cache/app-info.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { CACHE_APP_INFO, cachePath } from '../../../store/manager.js'
|
||||
import { YamlStore } from '../../../store/store.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { resumeApp } from '../../resume/app/run.js'
|
||||
import { runApp } from './run.js'
|
||||
@ -30,25 +30,18 @@ function bundle(): HostsBundle {
|
||||
describe('runApp', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-runapp-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('chat: prints answer + conversation hint to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -59,7 +52,7 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: rejects positional message with usage error', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -68,7 +61,7 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: prints single-string output as plain text', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -78,7 +71,7 @@ describe('runApp', () => {
|
||||
|
||||
it('json: passes through full envelope', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -111,7 +104,7 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream chat: streams answer to stdout and hint to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -123,7 +116,7 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream -o json chat: aggregates into blocking-shape envelope', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -136,7 +129,7 @@ describe('runApp', () => {
|
||||
|
||||
it('agent-chat without --stream: collects and prints answer', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -147,7 +140,7 @@ describe('runApp', () => {
|
||||
|
||||
it('agent-chat with --stream: live-prints answer and thoughts to stderr', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -158,7 +151,7 @@ describe('runApp', () => {
|
||||
|
||||
it('--stream workflow -o json: aggregates from workflow_finished', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -171,7 +164,7 @@ describe('runApp', () => {
|
||||
it('stream-error scenario: error event surfaces typed BaseError', async () => {
|
||||
mock.setScenario('stream-error')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
@ -180,7 +173,7 @@ describe('runApp', () => {
|
||||
|
||||
it('--inputs-file: reads inputs from file', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const inputsFile = join(dir, 'inputs.json')
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
|
||||
@ -204,7 +197,7 @@ describe('runApp', () => {
|
||||
|
||||
it('--inputs: accepts JSON object string', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -226,7 +219,7 @@ describe('runApp', () => {
|
||||
it('hitl pause (text): writes readable block to stdout, hint to stderr, exits 0', async () => {
|
||||
mock.setScenario('hitl-pause')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
let exitCode = -1
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
@ -255,7 +248,7 @@ describe('runApp', () => {
|
||||
it('hitl pause (json): writes JSON envelope to stdout, exits 0', async () => {
|
||||
mock.setScenario('hitl-pause')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
let exitCode = -1
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
@ -281,7 +274,7 @@ describe('runApp', () => {
|
||||
it('resume: withHistory: false completes successfully', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -292,7 +285,7 @@ describe('runApp', () => {
|
||||
it('resume: submits form and streams workflow to completion', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -303,7 +296,7 @@ describe('runApp', () => {
|
||||
it('resume --stream: live-prints workflow node progress to stderr', async () => {
|
||||
mock.setScenario('hitl-resume')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -314,7 +307,7 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: --file remote URL is passed as remote_url input variable', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
@ -333,7 +326,7 @@ describe('runApp', () => {
|
||||
it('workflow: --file @path uploads file and passes local_file input variable', async () => {
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
const filePath = join(dir, 'test.pdf')
|
||||
await writeFile(filePath, 'fake pdf content')
|
||||
await runApp(
|
||||
@ -352,7 +345,7 @@ describe('runApp', () => {
|
||||
|
||||
it('workflow: --file overrides same-named key from --inputs (file wins)', async () => {
|
||||
const io = bufferStreams()
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const cache = await loadAppInfoCache({ store: new YamlStore(cachePath(dir, CACHE_APP_INFO)) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
|
||||
@ -22,6 +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 }, {
|
||||
configDir: ctx.configDir,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
|
||||
@ -9,7 +9,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
@ -52,29 +51,23 @@ function fakeClient(opts: {
|
||||
describe('runUseWorkspace', () => {
|
||||
let configDir: string
|
||||
|
||||
let prevConfigDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
@ -89,7 +82,7 @@ describe('runUseWorkspace', () => {
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
const reloaded = loadHosts()
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
@ -100,15 +93,15 @@ describe('runUseWorkspace', () => {
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
await saveHosts(configDir, b)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = loadHosts()
|
||||
const reloaded = await loadHosts(configDir)
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
@ -116,8 +109,8 @@ describe('runUseWorkspace', () => {
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -127,6 +120,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
@ -136,7 +130,7 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = loadHosts()
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
@ -144,8 +138,8 @@ describe('runUseWorkspace', () => {
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
await saveHosts(configDir, b)
|
||||
const before = await loadHosts(configDir)
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -155,6 +149,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
@ -163,14 +158,14 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = loadHosts()
|
||||
const after = await loadHosts(configDir)
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
await saveHosts(configDir, b)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -192,6 +187,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
configDir,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
|
||||
@ -13,6 +13,7 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly configDir: string
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
@ -69,7 +70,7 @@ export async function runUseWorkspace(
|
||||
role: w.role,
|
||||
})),
|
||||
}
|
||||
saveHosts(next)
|
||||
await saveHosts(deps.configDir, next)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return next
|
||||
}
|
||||
|
||||
@ -1,52 +1,48 @@
|
||||
import type { YamlStore } from '../store/store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { isBaseError } from '../errors/base'
|
||||
import { ErrorCode } from '../errors/codes'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir'
|
||||
import { getConfigurationStore } from '../store/manager'
|
||||
import { YamlStore } from '../store/store'
|
||||
import { loadConfig } from './config-loader'
|
||||
import { FILE_NAME } from './schema'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-cfg-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await mkdir(dir, { recursive: true }).catch(() => {})
|
||||
})
|
||||
|
||||
it('returns found:false when config is missing', () => {
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
it('returns found:false when config.yml is missing', () => {
|
||||
const r = loadConfig(makeStore(dir))
|
||||
expect(r.found).toBe(false)
|
||||
})
|
||||
|
||||
it('parses a minimal valid config', () => {
|
||||
getConfigurationStore().setTyped({ schema_version: 1 })
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
it('parses a minimal valid config.yml', async () => {
|
||||
await writeFile(join(dir, FILE_NAME), 'schema_version: 1\n', 'utf8')
|
||||
const r = loadConfig(makeStore(dir))
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found)
|
||||
expect(r.config.schema_version).toBe(1)
|
||||
})
|
||||
|
||||
it('parses defaults + state', () => {
|
||||
getConfigurationStore().setTyped({
|
||||
schema_version: 1,
|
||||
defaults: { format: 'json', limit: 100 },
|
||||
state: { current_app: 'app-1' },
|
||||
})
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
it('parses defaults + state', async () => {
|
||||
await writeFile(
|
||||
join(dir, FILE_NAME),
|
||||
'schema_version: 1\ndefaults:\n format: json\n limit: 100\nstate:\n current_app: app-1\n',
|
||||
'utf8',
|
||||
)
|
||||
const r = loadConfig(makeStore(dir))
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found) {
|
||||
expect(r.config.defaults.format).toBe('json')
|
||||
@ -55,29 +51,11 @@ describe('loadConfig', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('throws BaseError(config_schema_unsupported) when the store fails to parse the file', () => {
|
||||
// Simulate a corrupt on-disk file via a fake store; loadConfig must wrap
|
||||
// the underlying error as ConfigSchemaUnsupported.
|
||||
const throwingStore = {
|
||||
getTyped: () => { throw new Error('YAML parse failure') },
|
||||
} as unknown as YamlStore
|
||||
it('throws BaseError(config_schema_unsupported) when YAML is malformed', async () => {
|
||||
await writeFile(join(dir, FILE_NAME), '::not yaml::: {{[', 'utf8')
|
||||
let caught: unknown
|
||||
try {
|
||||
loadConfig(throwingStore)
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
if (isBaseError(caught)) {
|
||||
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
|
||||
expect(caught.hint).toMatch(/not valid YAML/)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws BaseError(config_schema_unsupported) when zod validation fails', () => {
|
||||
getConfigurationStore().setTyped({ defaults: { limit: 9999 } })
|
||||
let caught: unknown
|
||||
try {
|
||||
loadConfig(getConfigurationStore())
|
||||
loadConfig(makeStore(dir))
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
@ -85,11 +63,23 @@ describe('loadConfig', () => {
|
||||
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
|
||||
})
|
||||
|
||||
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', () => {
|
||||
getConfigurationStore().setTyped({ schema_version: 2 })
|
||||
it('throws BaseError(config_schema_unsupported) when zod validation fails', async () => {
|
||||
await writeFile(join(dir, FILE_NAME), 'defaults:\n limit: 9999\n', 'utf8')
|
||||
let caught: unknown
|
||||
try {
|
||||
loadConfig(getConfigurationStore())
|
||||
loadConfig(makeStore(dir))
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
if (isBaseError(caught))
|
||||
expect(caught.code).toBe(ErrorCode.ConfigSchemaUnsupported)
|
||||
})
|
||||
|
||||
it('throws BaseError(config_schema_unsupported) when schema_version > 1 (forward-refuse)', async () => {
|
||||
await writeFile(join(dir, FILE_NAME), 'schema_version: 2\n', 'utf8')
|
||||
let caught: unknown
|
||||
try {
|
||||
loadConfig(makeStore(dir))
|
||||
}
|
||||
catch (err) { caught = err }
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CONFIG_FILE_NAME } from '../store/manager.js'
|
||||
import {
|
||||
ALLOWED_FORMATS,
|
||||
ConfigFileSchema,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
emptyConfig,
|
||||
FILE_NAME,
|
||||
} from './schema.js'
|
||||
|
||||
describe('config schema', () => {
|
||||
@ -12,8 +12,8 @@ describe('config schema', () => {
|
||||
expect(CURRENT_SCHEMA_VERSION).toBe(1)
|
||||
})
|
||||
|
||||
it('CONFIG_FILE_NAME is config.yml', () => {
|
||||
expect(CONFIG_FILE_NAME).toBe('config.yml')
|
||||
it('FILE_NAME is config.yml', () => {
|
||||
expect(FILE_NAME).toBe('config.yml')
|
||||
})
|
||||
|
||||
it('ALLOWED_FORMATS matches Go set (json/yaml/table/wide/name/text)', () => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CURRENT_SCHEMA_VERSION = 1
|
||||
export const FILE_NAME = 'config.yml'
|
||||
|
||||
export const ALLOWED_FORMATS = ['json', 'yaml', 'table', 'wide', 'name', 'text'] as const
|
||||
export type AllowedFormat = (typeof ALLOWED_FORMATS)[number]
|
||||
|
||||
@ -8,8 +8,8 @@ import {
|
||||
} from './codes.js'
|
||||
|
||||
describe('error codes', () => {
|
||||
it('has 18 codes (parity with internal/api/errors)', () => {
|
||||
expect(ALL_ERROR_CODES).toHaveLength(18)
|
||||
it('has 17 codes (parity with internal/api/errors)', () => {
|
||||
expect(ALL_ERROR_CODES).toHaveLength(17)
|
||||
})
|
||||
|
||||
it('has the expected ExitCode buckets', () => {
|
||||
@ -46,7 +46,6 @@ describe('error codes', () => {
|
||||
[ErrorCode.NetworkDns, ExitCode.Generic],
|
||||
[ErrorCode.Server5xx, ExitCode.Generic],
|
||||
[ErrorCode.Server4xxOther, ExitCode.Generic],
|
||||
[ErrorCode.ClientError, ExitCode.Generic],
|
||||
[ErrorCode.Unknown, ExitCode.Generic],
|
||||
])('exitFor(%s) -> %d', (code, want) => {
|
||||
expect(exitFor(code)).toBe(want)
|
||||
|
||||
@ -15,7 +15,6 @@ export const ErrorCode = {
|
||||
NetworkDns: 'network_dns',
|
||||
Server5xx: 'server_5xx',
|
||||
Server4xxOther: 'server_4xx_other',
|
||||
ClientError: 'client_error',
|
||||
Unknown: 'unknown',
|
||||
} as const
|
||||
|
||||
@ -48,7 +47,6 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
|
||||
network_dns: ExitCode.Generic,
|
||||
server_5xx: ExitCode.Generic,
|
||||
server_4xx_other: ExitCode.Generic,
|
||||
client_error: ExitCode.Generic,
|
||||
unknown: ExitCode.Generic,
|
||||
}
|
||||
|
||||
|
||||
@ -1,57 +1,45 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { loadConfig } from '../config/config-loader'
|
||||
import { emptyConfig } from '../config/schema'
|
||||
import { emptyConfig, FILE_NAME } from '../config/schema'
|
||||
import { platform } from '../sys'
|
||||
import { saveConfig } from './config-writer'
|
||||
import { ENV_CONFIG_DIR } from './dir'
|
||||
import { getConfigurationStore } from './manager'
|
||||
import { YamlStore } from './store'
|
||||
|
||||
function makeStore(dir: string): YamlStore {
|
||||
return new YamlStore(join(dir, FILE_NAME))
|
||||
}
|
||||
|
||||
describe('saveConfig', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-w-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
it('writes config.yml in the target dir', async () => {
|
||||
saveConfig(makeStore(dir), { ...emptyConfig(), schema_version: 1 })
|
||||
const stats = await stat(join(dir, FILE_NAME))
|
||||
expect(stats.isFile()).toBe(true)
|
||||
})
|
||||
|
||||
it('stamps schema_version=1 even if caller passed 0', () => {
|
||||
saveConfig(getConfigurationStore(), { ...emptyConfig() })
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
saveConfig(makeStore(dir), { ...emptyConfig() })
|
||||
const r = loadConfig(makeStore(dir))
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found)
|
||||
expect(r.config.schema_version).toBe(1)
|
||||
})
|
||||
|
||||
it('overrides a stale schema_version on save', () => {
|
||||
saveConfig(getConfigurationStore(), {
|
||||
...emptyConfig(),
|
||||
schema_version: 999 as never,
|
||||
})
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found)
|
||||
expect(r.config.schema_version).toBe(1)
|
||||
})
|
||||
|
||||
it('round-trips defaults + state', () => {
|
||||
saveConfig(getConfigurationStore(), {
|
||||
it('round-trips defaults + state through YAML', () => {
|
||||
saveConfig(makeStore(dir), {
|
||||
schema_version: 1,
|
||||
defaults: { format: 'wide', limit: 75 },
|
||||
state: { current_app: 'app-xyz' },
|
||||
})
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
const r = loadConfig(makeStore(dir))
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found) {
|
||||
expect(r.config.defaults.format).toBe('wide')
|
||||
@ -60,22 +48,39 @@ describe('saveConfig', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('overwrites the previous config on resave', () => {
|
||||
saveConfig(getConfigurationStore(), {
|
||||
it('writes file with mode 0o600 (POSIX)', async () => {
|
||||
if (platform() === 'win32')
|
||||
return
|
||||
saveConfig(makeStore(dir), emptyConfig())
|
||||
const s = await stat(join(dir, FILE_NAME))
|
||||
expect(s.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('does not leave a tmp file on success', async () => {
|
||||
saveConfig(makeStore(dir), emptyConfig())
|
||||
const entries = await readdir(dir)
|
||||
expect(entries.filter(f => f.endsWith('.tmp'))).toHaveLength(0)
|
||||
expect(entries.filter(f => f.includes('.tmp.'))).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('creates parent dir at 0o700 if absent', async () => {
|
||||
if (platform() === 'win32')
|
||||
return
|
||||
const nested = join(dir, 'nested', 'sub')
|
||||
saveConfig(makeStore(nested), emptyConfig())
|
||||
const s = await stat(nested)
|
||||
expect(s.isDirectory()).toBe(true)
|
||||
expect(s.mode & 0o777).toBe(0o700)
|
||||
})
|
||||
|
||||
it('emits parseable YAML (round-trip via fs.readFile + js-yaml)', async () => {
|
||||
saveConfig(makeStore(dir), {
|
||||
schema_version: 1,
|
||||
defaults: { format: 'json' },
|
||||
state: {},
|
||||
})
|
||||
saveConfig(getConfigurationStore(), {
|
||||
schema_version: 1,
|
||||
defaults: { format: 'table' },
|
||||
state: { current_app: 'app-2' },
|
||||
})
|
||||
const r = loadConfig(getConfigurationStore())
|
||||
expect(r.found).toBe(true)
|
||||
if (r.found) {
|
||||
expect(r.config.defaults.format).toBe('table')
|
||||
expect(r.config.state.current_app).toBe('app-2')
|
||||
}
|
||||
const raw = await readFile(join(dir, FILE_NAME), 'utf8')
|
||||
expect(raw).toMatch(/^schema_version:/m)
|
||||
expect(raw).toMatch(/format: json/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,64 +0,0 @@
|
||||
import { BaseError } from '../errors/base'
|
||||
import { ErrorCode } from '../errors/codes'
|
||||
|
||||
export class ConcurrentAccessError extends BaseError {
|
||||
constructor(filePath: string) {
|
||||
const msg = `Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`
|
||||
|
||||
super({
|
||||
code: ErrorCode.ClientError,
|
||||
message: msg,
|
||||
hint: `remove ${filePath}.lock to reset lock.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type YamlMark = {
|
||||
line: number
|
||||
column: number
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
type YamlParseError = {
|
||||
reason?: string
|
||||
mark?: YamlMark
|
||||
message?: string
|
||||
}
|
||||
|
||||
export class BadYamlFormatError extends BaseError {
|
||||
constructor(path: string, raw: string, cause: YamlParseError) {
|
||||
const reason = cause.reason ?? cause.message ?? 'invalid YAML'
|
||||
const mark = cause.mark
|
||||
const where = mark ? ` at line ${mark.line + 1}, column ${mark.column + 1}` : ''
|
||||
const snippet = mark?.snippet ?? excerpt(raw, mark)
|
||||
const header = `Failed to parse YAML file ${path}: ${reason}${where}.`
|
||||
const body = snippet ? `\n\n${snippet}` : ''
|
||||
|
||||
super({
|
||||
code: ErrorCode.ClientError,
|
||||
message: `${header}${body}`,
|
||||
hint: `Fix the YAML syntax in ${path} or remove the file to reset it.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function excerpt(raw: string, mark: YamlMark | undefined): string {
|
||||
if (mark === undefined)
|
||||
return ''
|
||||
const lines = raw.split('\n')
|
||||
const target = mark.line
|
||||
if (target < 0 || target >= lines.length)
|
||||
return ''
|
||||
const start = Math.max(0, target - 2)
|
||||
const end = Math.min(lines.length, target + 3)
|
||||
const width = String(end).length
|
||||
const out: string[] = []
|
||||
for (let i = start; i < end; i++) {
|
||||
const marker = i === target ? '>' : ' '
|
||||
const num = String(i + 1).padStart(width, ' ')
|
||||
out.push(`${marker} ${num} | ${lines[i]}`)
|
||||
if (i === target)
|
||||
out.push(`${' '.repeat(width + 4)}${' '.repeat(mark.column)}^`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const passwords = new Map<string, string>()
|
||||
const setPassword = vi.fn()
|
||||
const getPassword = vi.fn()
|
||||
const deletePassword = vi.fn()
|
||||
|
||||
class FakeEntry {
|
||||
private readonly key: string
|
||||
constructor(service: string, username: string) {
|
||||
this.key = `${service}::${username}`
|
||||
}
|
||||
|
||||
setPassword(value: string): void {
|
||||
setPassword(this.key, value)
|
||||
passwords.set(this.key, value)
|
||||
}
|
||||
|
||||
getPassword(): string | null {
|
||||
getPassword(this.key)
|
||||
return passwords.get(this.key) ?? null
|
||||
}
|
||||
|
||||
deletePassword(): boolean {
|
||||
deletePassword(this.key)
|
||||
if (!passwords.has(this.key))
|
||||
return false
|
||||
passwords.delete(this.key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@napi-rs/keyring', () => ({
|
||||
Entry: FakeEntry,
|
||||
}))
|
||||
|
||||
const { KeyringBasedStore } = await import('./store.js')
|
||||
|
||||
const SERVICE = 'difyctl-test'
|
||||
|
||||
beforeEach(() => {
|
||||
passwords.clear()
|
||||
setPassword.mockClear()
|
||||
getPassword.mockClear()
|
||||
deletePassword.mockClear()
|
||||
})
|
||||
|
||||
describe('KeyringBasedStore', () => {
|
||||
it('returns default when entry missing', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
expect(s.get({ key: 'k', default: 'fallback' })).toBe('fallback')
|
||||
})
|
||||
|
||||
it('round-trips strings via JSON encoding', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
s.set({ key: 'k', default: '' }, 'tok-abc')
|
||||
expect(s.get({ key: 'k', default: '' })).toBe('tok-abc')
|
||||
})
|
||||
|
||||
it('isolates entries by key', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
s.set({ key: 'a', default: '' }, 'A')
|
||||
s.set({ key: 'b', default: '' }, 'B')
|
||||
expect(s.get({ key: 'a', default: '' })).toBe('A')
|
||||
expect(s.get({ key: 'b', default: '' })).toBe('B')
|
||||
})
|
||||
|
||||
it('unset removes the entry', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
s.set({ key: 'k', default: '' }, 'v')
|
||||
s.unset({ key: 'k', default: '' })
|
||||
expect(s.get({ key: 'k', default: '' })).toBe('')
|
||||
})
|
||||
|
||||
it('unset is a no-op when entry missing', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
expect(() => s.unset({ key: 'gone', default: '' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('swallows getPassword exceptions and returns default', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
getPassword.mockImplementationOnce(
|
||||
() => {
|
||||
throw new Error('NoEntry')
|
||||
},
|
||||
)
|
||||
expect(s.get({ key: 'k', default: 'd' })).toBe('d')
|
||||
})
|
||||
|
||||
it('swallows unset exceptions', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
deletePassword.mockImplementationOnce(
|
||||
() => {
|
||||
throw new Error('NoEntry')
|
||||
},
|
||||
)
|
||||
expect(() => s.unset({ key: 'k', default: '' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('lets set propagate exceptions (caller decides fallback)', () => {
|
||||
const s = new KeyringBasedStore(SERVICE)
|
||||
setPassword.mockImplementationOnce(
|
||||
() => {
|
||||
throw new Error('keyring locked')
|
||||
},
|
||||
)
|
||||
expect(() => s.set({ key: 'k', default: '' }, 'v')).toThrow(/keyring locked/)
|
||||
})
|
||||
})
|
||||
@ -1,78 +0,0 @@
|
||||
import type { Key, Store } from './store.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getTokenStore } from './manager.js'
|
||||
|
||||
function memStore(label: string): Store & { _label: string } {
|
||||
const map = new Map<string, unknown>()
|
||||
return {
|
||||
_label: label,
|
||||
get<T>(key: Key<T>): T {
|
||||
return (map.get(key.key) as T | undefined) ?? key.default
|
||||
},
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
map.set(key.key, value)
|
||||
},
|
||||
unset<T>(key: Key<T>): void {
|
||||
map.delete(key.key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('getTokenStore', () => {
|
||||
it('returns keychain store when probe succeeds', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
const result = getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('keychain')
|
||||
expect(result.store).toBe(k)
|
||||
})
|
||||
|
||||
it('falls back to file when keyring set throws', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
k.set = vi.fn(
|
||||
() => {
|
||||
throw new Error('locked')
|
||||
},
|
||||
)
|
||||
const result = getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('falls back to file when probe round-trip mismatches', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
k.get = vi.fn(() => 'something-else')
|
||||
const result = getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('falls back to file when keyring constructor throws', () => {
|
||||
const f = memStore('file')
|
||||
const result = getTokenStore({
|
||||
factory: {
|
||||
keyring: () => { throw new Error('no backend') },
|
||||
file: () => f,
|
||||
},
|
||||
})
|
||||
expect(result.mode).toBe('file')
|
||||
expect(result.store).toBe(f)
|
||||
})
|
||||
|
||||
it('cleans up probe entry after successful probe', () => {
|
||||
const k = memStore('keyring')
|
||||
const f = memStore('file')
|
||||
getTokenStore({
|
||||
factory: { keyring: () => k, file: () => f },
|
||||
})
|
||||
expect(k.get({ key: '__difyctl_probe__', default: '' })).toBe('')
|
||||
})
|
||||
})
|
||||
@ -1,77 +1,28 @@
|
||||
import type { Key, StorageMode, Store } from './store'
|
||||
import type { Store } from './store'
|
||||
import { join } from 'node:path'
|
||||
import { FILE_NAME } from '../config/schema'
|
||||
import { resolveCacheDir, resolveConfigDir } from './dir'
|
||||
import { KeyringBasedStore, YamlStore } from './store'
|
||||
import { YamlStore } from './store'
|
||||
|
||||
export const CACHE_APP_INFO = 'app-info'
|
||||
export const CACHE_NUDGE = 'nudge'
|
||||
const HOSTS_FILE = 'hosts.yml'
|
||||
const TOKENS_FILE = 'tokens.yml'
|
||||
export const CONFIG_FILE_NAME = 'config.yml'
|
||||
|
||||
const KEYRING_SERVICE = 'difyctl'
|
||||
|
||||
function getStore(filePath: string): YamlStore {
|
||||
return new YamlStore(filePath)
|
||||
}
|
||||
|
||||
function resolveConfigurationPath(): string {
|
||||
return join(resolveConfigDir(), FILE_NAME)
|
||||
}
|
||||
|
||||
export function cachePath(cacheDir: string, name: string): string {
|
||||
return join(cacheDir, `${name}.yml`)
|
||||
}
|
||||
|
||||
export function getConfigurationStore(): YamlStore {
|
||||
return getStore(join(resolveConfigDir(), CONFIG_FILE_NAME))
|
||||
return getStore(resolveConfigurationPath())
|
||||
}
|
||||
|
||||
export function getCache(cacheName: string): Store {
|
||||
return getStore(cachePath(resolveCacheDir(), cacheName))
|
||||
}
|
||||
|
||||
export function getHostStore(): YamlStore {
|
||||
return getStore(join(resolveConfigDir(), HOSTS_FILE))
|
||||
}
|
||||
|
||||
const PROBE_KEY: Key<string> = { key: '__difyctl_probe__', default: '' }
|
||||
const PROBE_VALUE = 'probe-v1'
|
||||
|
||||
export type GetTokenStoreOptions = {
|
||||
readonly factory?: {
|
||||
readonly keyring?: () => Store
|
||||
readonly file?: () => Store
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single entry point for the credential store. Probes the OS keyring; if it
|
||||
* round-trips a value, returns the keychain-backed store. Otherwise falls
|
||||
* back to the YAML file at `<configDir>/tokens.yml`. Both implementations
|
||||
* satisfy the `Store` interface, so callers interact uniformly.
|
||||
*
|
||||
* Business logic should always obtain the token store through this factory
|
||||
* rather than constructing one directly.
|
||||
*/
|
||||
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } {
|
||||
const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE)))
|
||||
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE))
|
||||
try {
|
||||
const k = keyringFactory()
|
||||
k.set(PROBE_KEY, PROBE_VALUE)
|
||||
const got = k.get(PROBE_KEY)
|
||||
k.unset(PROBE_KEY)
|
||||
if (got !== PROBE_VALUE)
|
||||
throw new Error('keyring round-trip mismatch')
|
||||
return { store: k, mode: 'keychain' }
|
||||
}
|
||||
catch {
|
||||
return { store: fileFactory(), mode: 'file' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an auth identity (host + accountId) to a `Store` key. All token store
|
||||
* reads/writes in business logic go through this helper so the on-disk /
|
||||
* keyring layout stays consistent.
|
||||
*/
|
||||
export function tokenKey(host: string, accountId: string): Key<string> {
|
||||
return { key: `tokens.${host}.${accountId}`, default: '' }
|
||||
}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, 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 { BadYamlFormatError, ConcurrentAccessError } from './errors'
|
||||
import { YamlStore } from './store'
|
||||
import { ConcurrentAccessError, YamlStore } from './store'
|
||||
|
||||
describe('YamlStore.doGet', () => {
|
||||
it('returns default when content is undefined', () => {
|
||||
@ -14,51 +13,33 @@ describe('YamlStore.doGet', () => {
|
||||
|
||||
it('reads a flat key', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\n')
|
||||
store.raw_content = 'name: alice\n'
|
||||
expect(store.doGet({ key: 'name', default: '' })).toBe('alice')
|
||||
})
|
||||
|
||||
it('reads a nested key via dot notation', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('user:\n id: 42\n')
|
||||
store.raw_content = 'user:\n id: 42\n'
|
||||
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(42)
|
||||
})
|
||||
|
||||
it('returns default for a missing flat key', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\n')
|
||||
store.raw_content = 'name: alice\n'
|
||||
expect(store.doGet({ key: 'age', default: -1 })).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns default when an intermediate path segment is absent', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('user:\n name: bob\n')
|
||||
store.raw_content = 'user:\n name: bob\n'
|
||||
expect(store.doGet({ key: 'user.address.city', default: 'unknown' })).toBe('unknown')
|
||||
})
|
||||
|
||||
it('returns default when an intermediate path segment is a scalar', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('user: scalar\n')
|
||||
store.raw_content = 'user: scalar\n'
|
||||
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(0)
|
||||
})
|
||||
|
||||
it('throws BadYamlFormatError with file path, location, and snippet for malformed YAML', () => {
|
||||
const path = '/irrelevant'
|
||||
const store = new YamlStore(path)
|
||||
store.setRawContent('name: alice\nuser:\n id: 42\n bad: indent\n')
|
||||
let caught: unknown
|
||||
try {
|
||||
store.doGet({ key: 'name', default: '' })
|
||||
}
|
||||
catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BadYamlFormatError)
|
||||
const msg = (caught as BadYamlFormatError).message
|
||||
expect(msg).toContain(path)
|
||||
expect(msg).toMatch(/line \d+, column \d+/)
|
||||
expect(msg).toContain('bad: indent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('YamlStore.doSet', () => {
|
||||
@ -76,7 +57,7 @@ describe('YamlStore.doSet', () => {
|
||||
|
||||
it('overwrites an existing key without disturbing siblings', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('name: alice\nage: 30\n')
|
||||
store.raw_content = 'name: alice\nage: 30\n'
|
||||
store.doSet({ key: 'name', default: '' }, 'bob')
|
||||
expect(store.doGet({ key: 'name', default: '' })).toBe('bob')
|
||||
expect(store.doGet({ key: 'age', default: 0 })).toBe(30)
|
||||
@ -84,7 +65,7 @@ describe('YamlStore.doSet', () => {
|
||||
|
||||
it('replaces a scalar intermediate with an object when path deepens', () => {
|
||||
const store = new YamlStore('/irrelevant')
|
||||
store.setRawContent('user: scalar\n')
|
||||
store.raw_content = 'user: scalar\n'
|
||||
store.doSet({ key: 'user.id', default: 0 }, 99)
|
||||
expect(store.doGet({ key: 'user.id', default: 0 })).toBe(99)
|
||||
})
|
||||
@ -151,12 +132,12 @@ describe('YamlStore persistence', () => {
|
||||
await writeFile(path, '')
|
||||
|
||||
const s1 = new YamlStore(path)
|
||||
s1.setRawContent('')
|
||||
s1.raw_content = ''
|
||||
s1.doSet({ key: 'workspace', default: '' }, 'ws-123')
|
||||
writeFileSync(path, s1.getRawContent() ?? '')
|
||||
writeFileSync(path, s1.raw_content ?? '')
|
||||
|
||||
const s2 = new YamlStore(path)
|
||||
s2.setRawContent(readFileSync(path, 'utf8'))
|
||||
s2.raw_content = readFileSync(path, 'utf8')
|
||||
expect(s2.doGet({ key: 'workspace', default: '' })).toBe('ws-123')
|
||||
})
|
||||
|
||||
@ -165,12 +146,12 @@ describe('YamlStore persistence', () => {
|
||||
await writeFile(path, '')
|
||||
|
||||
const s1 = new YamlStore(path)
|
||||
s1.setRawContent('')
|
||||
s1.raw_content = ''
|
||||
s1.doSet({ key: 'a.b.c', default: '' }, 'deep')
|
||||
writeFileSync(path, s1.getRawContent() ?? '')
|
||||
writeFileSync(path, s1.raw_content ?? '')
|
||||
|
||||
const s2 = new YamlStore(path)
|
||||
s2.setRawContent(readFileSync(path, 'utf8'))
|
||||
s2.raw_content = readFileSync(path, 'utf8')
|
||||
expect(s2.doGet({ key: 'a.b.c', default: '' })).toBe('deep')
|
||||
})
|
||||
|
||||
@ -179,17 +160,17 @@ describe('YamlStore persistence', () => {
|
||||
await writeFile(path, '')
|
||||
|
||||
const s1 = new YamlStore(path)
|
||||
s1.setRawContent('')
|
||||
s1.raw_content = ''
|
||||
s1.doSet({ key: 'x', default: '' }, 'first')
|
||||
writeFileSync(path, s1.getRawContent() ?? '')
|
||||
writeFileSync(path, s1.raw_content ?? '')
|
||||
|
||||
const s2 = new YamlStore(path)
|
||||
s2.setRawContent(readFileSync(path, 'utf8'))
|
||||
s2.raw_content = readFileSync(path, 'utf8')
|
||||
s2.doSet({ key: 'y', default: '' }, 'second')
|
||||
writeFileSync(path, s2.getRawContent() ?? '')
|
||||
writeFileSync(path, s2.raw_content ?? '')
|
||||
|
||||
const s3 = new YamlStore(path)
|
||||
s3.setRawContent(readFileSync(path, 'utf8'))
|
||||
s3.raw_content = readFileSync(path, 'utf8')
|
||||
expect(s3.doGet({ key: 'x', default: '' })).toBe('first')
|
||||
expect(s3.doGet({ key: 'y', default: '' })).toBe('second')
|
||||
})
|
||||
@ -205,28 +186,8 @@ describe('YamlStore persistence', () => {
|
||||
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const store2 = new YamlStore(path)
|
||||
store2.setRawContent(raw)
|
||||
store2.raw_content = raw
|
||||
expect(store2.doGet({ key: 'token', default: '' })).toBe('abc-123')
|
||||
expect(store2.doGet({ key: 'existing', default: '' })).toBe('value')
|
||||
})
|
||||
|
||||
it('flush writes file when dirty (content changed from undefined)', () => {
|
||||
const path = join(dir, 'config.yml')
|
||||
const store = new YamlStore(path)
|
||||
store.setRawContent('key: value\n')
|
||||
store.flush()
|
||||
expect(existsSync(path)).toBe(true)
|
||||
expect(readFileSync(path, 'utf8')).toBe('key: value\n')
|
||||
})
|
||||
|
||||
it('flush is a no-op when loaded content is set back unchanged', async () => {
|
||||
const path = join(dir, 'config.yml')
|
||||
await writeFile(path, 'key: value\n')
|
||||
const store = new YamlStore(path)
|
||||
store.load()
|
||||
const mtime = statSync(path).mtimeMs
|
||||
store.setRawContent('key: value\n')
|
||||
store.flush()
|
||||
expect(statSync(path).mtimeMs).toBe(mtime)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import type { Platform } from '../sys'
|
||||
import fs from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { Entry } from '@napi-rs/keyring'
|
||||
import yaml from 'js-yaml'
|
||||
import lockfile from 'lockfile'
|
||||
import { pid, resolvePlatform } from '../sys'
|
||||
import { BadYamlFormatError, ConcurrentAccessError } from './errors'
|
||||
|
||||
const FILE_PERM = 0o600
|
||||
const DIR_PERM = 0o700
|
||||
|
||||
export type Key<T> = {
|
||||
type Key<T> = {
|
||||
default: T
|
||||
key: string
|
||||
}
|
||||
@ -18,43 +16,38 @@ export type Key<T> = {
|
||||
export type Store = {
|
||||
get: <T>(key: Key<T>) => T
|
||||
set: <T>(key: Key<T>, value: T) => void
|
||||
unset: <T>(key: Key<T>) => void
|
||||
}
|
||||
|
||||
export type StorageMode = 'keychain' | 'file'
|
||||
export class ConcurrentAccessError extends Error {
|
||||
constructor(filePath: string) {
|
||||
super(`Another process is modifying the file ${filePath}. remove ${filePath}.lock to reset lock.`)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class FileBasedStore implements Store {
|
||||
filePath: string
|
||||
private rawContent: string | undefined
|
||||
file_path: string
|
||||
raw_content: string | undefined
|
||||
private readonly platform: Platform
|
||||
private dirty: boolean = false
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath
|
||||
constructor(file_path: string) {
|
||||
this.file_path = file_path
|
||||
this.platform = resolvePlatform()
|
||||
fs.mkdirSync(dirname(this.file_path), { recursive: true, mode: DIR_PERM })
|
||||
}
|
||||
|
||||
unlock(): void {
|
||||
lockfile.unlockSync(`${this.filePath}.lock`)
|
||||
lockfile.unlockSync(`${this.file_path}.lock`)
|
||||
}
|
||||
|
||||
/**
|
||||
* atomically write raw_content (if any)
|
||||
*/
|
||||
flush(): void {
|
||||
fs.mkdirSync(dirname(this.filePath), { recursive: true, mode: DIR_PERM })
|
||||
|
||||
// we don't handle A-B-A scenario,
|
||||
// which is not likely to happen in cli
|
||||
if (!this.dirty) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.rawContent !== undefined) {
|
||||
const tmp = `${this.filePath}.tmp.${pid()}.${Date.now()}`
|
||||
if (this.raw_content !== undefined) {
|
||||
const tmp = `${this.file_path}.tmp.${pid()}.${Date.now()}`
|
||||
try {
|
||||
fs.writeFileSync(tmp, this.rawContent, { mode: FILE_PERM })
|
||||
this.platform.atomicReplace(tmp, this.filePath)
|
||||
fs.writeFileSync(tmp, this.raw_content, { mode: FILE_PERM })
|
||||
this.platform.atomicReplace(tmp, this.file_path)
|
||||
}
|
||||
catch (err) {
|
||||
try {
|
||||
@ -64,20 +57,16 @@ abstract class FileBasedStore implements Store {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
this.dirty = false
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
try {
|
||||
lockfile.lockSync(`${this.filePath}.lock`, {
|
||||
stale: 30_000,
|
||||
})
|
||||
lockfile.lockSync(`${this.file_path}.lock`)
|
||||
}
|
||||
catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EEXIST') {
|
||||
throw new ConcurrentAccessError(this.filePath)
|
||||
throw new ConcurrentAccessError(this.file_path)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@ -85,8 +74,7 @@ abstract class FileBasedStore implements Store {
|
||||
|
||||
load(): void {
|
||||
try {
|
||||
this.rawContent = fs.readFileSync(this.filePath, 'utf8')
|
||||
this.dirty = false
|
||||
this.raw_content = fs.readFileSync(this.file_path, 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
@ -96,18 +84,10 @@ abstract class FileBasedStore implements Store {
|
||||
}
|
||||
}
|
||||
|
||||
public setRawContent(content: string): void {
|
||||
this.dirty = (content !== this.getRawContent())
|
||||
this.rawContent = content
|
||||
}
|
||||
|
||||
public getRawContent(): string | undefined {
|
||||
return this.rawContent
|
||||
}
|
||||
|
||||
protected withLock<R>(body: () => R): R {
|
||||
this.lock()
|
||||
try {
|
||||
this.load()
|
||||
return body()
|
||||
}
|
||||
finally {
|
||||
@ -116,44 +96,18 @@ abstract class FileBasedStore implements Store {
|
||||
}
|
||||
|
||||
get<T>(key: Key<T>): T {
|
||||
return this.withLock(() => {
|
||||
this.load()
|
||||
return this.doGet(key)
|
||||
})
|
||||
return this.withLock(() => this.doGet(key))
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T) {
|
||||
this.withLock(() => {
|
||||
this.load()
|
||||
this.doSet(key, value)
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.withLock(() => {
|
||||
this.load()
|
||||
this.doUnset(key)
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the underlying file of the store. No-op if file doesn't exist.
|
||||
*/
|
||||
rm(): void {
|
||||
try {
|
||||
fs.unlinkSync(this.filePath)
|
||||
}
|
||||
catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
abstract doGet<T>(key: Key<T>): T
|
||||
abstract doSet<T>(key: Key<T>, value: T): void
|
||||
abstract doUnset<T>(key: Key<T>): void
|
||||
}
|
||||
|
||||
export class YamlStore extends FileBasedStore {
|
||||
@ -162,7 +116,7 @@ export class YamlStore extends FileBasedStore {
|
||||
}
|
||||
|
||||
doGet<T>(key: Key<T>): T {
|
||||
const data = loadYaml(this.getRawContent(), this.filePath)
|
||||
const data = loadYaml(this.raw_content)
|
||||
const parts = key.key.split('.')
|
||||
let current: unknown = data
|
||||
for (const part of parts) {
|
||||
@ -176,20 +130,19 @@ export class YamlStore extends FileBasedStore {
|
||||
getTyped<T>(): T | null {
|
||||
return this.withLock(() => {
|
||||
this.load()
|
||||
return loadYaml(this.getRawContent(), this.filePath) as T
|
||||
return loadYaml(this.raw_content) as T
|
||||
})
|
||||
}
|
||||
|
||||
setTyped<T>(data: T): void {
|
||||
this.withLock(() => {
|
||||
this.load()
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
|
||||
this.flush()
|
||||
})
|
||||
}
|
||||
|
||||
doSet<T>(key: Key<T>, value: T): void {
|
||||
const data = loadYaml(this.getRawContent(), this.filePath) || {}
|
||||
const data = loadYaml(this.raw_content) || {}
|
||||
const parts = key.key.split('.')
|
||||
const lastKey = parts.pop()
|
||||
if (lastKey === undefined)
|
||||
@ -201,74 +154,12 @@ export class YamlStore extends FileBasedStore {
|
||||
current = current[part] as Record<string, unknown>
|
||||
}
|
||||
current[lastKey] = value
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
}
|
||||
|
||||
doUnset<T>(key: Key<T>): void {
|
||||
const data = loadYaml(this.getRawContent(), this.filePath) || {}
|
||||
const parts = key.key.split('.')
|
||||
const lastKey = parts.pop()
|
||||
if (lastKey === undefined)
|
||||
return
|
||||
let current: Record<string, unknown> = data
|
||||
for (const part of parts) {
|
||||
const next = current[part]
|
||||
if (next === null || next === undefined || typeof next !== 'object')
|
||||
return
|
||||
current = next as Record<string, unknown>
|
||||
}
|
||||
if (!(lastKey in current))
|
||||
return
|
||||
delete current[lastKey]
|
||||
this.setRawContent(yaml.dump(data, { lineWidth: -1, noRefs: true }))
|
||||
this.raw_content = yaml.dump(data, { lineWidth: -1, noRefs: true })
|
||||
}
|
||||
}
|
||||
|
||||
function loadYaml(raw: string | undefined, file_path: string): Record<string, unknown> | null {
|
||||
function loadYaml(raw: string | undefined): Record<string, unknown> | null {
|
||||
if (raw === undefined)
|
||||
return null
|
||||
try {
|
||||
return (yaml.load(raw) ?? {}) as Record<string, unknown>
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof yaml.YAMLException)
|
||||
throw new BadYamlFormatError(file_path, raw, err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OS-keyring-based storage primitive. Sits at the same layer as
|
||||
* `FileBasedStore`: implements `Store` with each `Key<T>` corresponding to a
|
||||
* single keyring entry under the configured service. Values are JSON-encoded.
|
||||
*/
|
||||
export class KeyringBasedStore implements Store {
|
||||
private readonly service: string
|
||||
|
||||
constructor(service: string) {
|
||||
this.service = service
|
||||
}
|
||||
|
||||
get<T>(key: Key<T>): T {
|
||||
try {
|
||||
const v = new Entry(this.service, key.key).getPassword()
|
||||
if (v === null || v === undefined || v === '')
|
||||
return key.default
|
||||
return JSON.parse(v) as T
|
||||
}
|
||||
catch {
|
||||
return key.default
|
||||
}
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
new Entry(this.service, key.key).setPassword(JSON.stringify(value))
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
try {
|
||||
new Entry(this.service, key.key).deletePassword()
|
||||
}
|
||||
catch { /* missing entry is fine */ }
|
||||
}
|
||||
return (yaml.load(raw) ?? {}) as Record<string, unknown>
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadNudgeStore } from '../cache/nudge-store.js'
|
||||
import { ENV_CACHE_DIR } from '../store/dir.js'
|
||||
import { CACHE_NUDGE, getCache } from '../store/manager.js'
|
||||
import { CACHE_NUDGE, cachePath } from '../store/manager.js'
|
||||
import { YamlStore } from '../store/store.js'
|
||||
import { maybeNudgeCompat } from './nudge.js'
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
@ -44,18 +44,11 @@ describe('maybeNudgeCompat', () => {
|
||||
let dir: string
|
||||
let store: NudgeStore
|
||||
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
|
||||
store = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
@ -85,12 +78,12 @@ describe('maybeNudgeCompat', () => {
|
||||
|
||||
it('warns again after the silence window has elapsed', async () => {
|
||||
const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000)
|
||||
const tStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => yesterday })
|
||||
const tStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: () => yesterday })
|
||||
await tStore.markWarned(HOST)
|
||||
const probe = vi.fn(async () => UNSUPPORTED)
|
||||
const { emit, lines } = emitterSpy()
|
||||
|
||||
const freshStore = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: fixedNow })
|
||||
const freshStore = await loadNudgeStore({ store: new YamlStore(cachePath(dir, CACHE_NUDGE)), now: fixedNow })
|
||||
await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit }))
|
||||
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
@ -160,8 +160,7 @@ describe('runVersionProbe', () => {
|
||||
const url = new URL(mock.url)
|
||||
const prevConfig = process.env[ENV_CONFIG_DIR]
|
||||
try {
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
saveHosts({
|
||||
await saveHosts(configDir, {
|
||||
current_host: url.host,
|
||||
scheme: url.protocol.replace(':', ''),
|
||||
token_storage: 'file',
|
||||
|
||||
@ -5,6 +5,7 @@ import type { Channel } from './info.js'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
|
||||
import { loadHosts } from '../auth/hosts.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { resolveConfigDir } from '../store/dir.js'
|
||||
import { arch, platform } from '../sys/index.js'
|
||||
import { hostWithScheme } from '../util/host.js'
|
||||
import { difyCompat, evaluateCompat } from './compat.js'
|
||||
@ -47,7 +48,7 @@ export type RunVersionProbeOptions = {
|
||||
readonly probe?: MetaProbe
|
||||
}
|
||||
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts(resolveConfigDir())
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
|
||||
@ -222,10 +222,6 @@ QUEUE_MONITOR_INTERVAL=30
|
||||
SWAGGER_UI_ENABLED=false
|
||||
SWAGGER_UI_PATH=/swagger-ui.html
|
||||
OPENAPI_ENABLED=false
|
||||
OPENAPI_CORS_ALLOW_ORIGINS=
|
||||
OPENAPI_KNOWN_CLIENT_IDS=difyctl
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN=60
|
||||
ENABLE_OAUTH_BEARER=false
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
DATASET_MAX_SEGMENTS_PER_REQUEST=0
|
||||
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false
|
||||
|
||||
@ -1,286 +0,0 @@
|
||||
# Cross-Environment Data Migration
|
||||
|
||||
This guide explains how to move workflow apps, chatflow apps, and their custom tool dependencies from one Dify environment to another.
|
||||
|
||||
The recommended best-practice path is:
|
||||
|
||||
1. Run the export wizard in the source environment.
|
||||
2. Import the generated migration package in the target environment.
|
||||
|
||||
Use scripted export only when you need repeatable automation.
|
||||
|
||||
## What Gets Migrated
|
||||
|
||||
The migration package can contain:
|
||||
|
||||
- Workflow apps and advanced-chat apps.
|
||||
- Custom API tool providers.
|
||||
- Workflow tool providers.
|
||||
- MCP tool providers when secrets are explicitly included.
|
||||
- Dependency metadata for MCP tools, built-in tools, and plugin tools that must already exist or be configured in the target environment.
|
||||
|
||||
Only single source workspace exports are supported. The source and target workspace names do not need to match.
|
||||
|
||||
## Recommended Flow: Wizard Export + Import
|
||||
|
||||
Run the wizard from the source environment:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask app-migration-wizard
|
||||
```
|
||||
|
||||
The wizard asks you to select the source workspace, choose workflow/chatflow apps, discover referenced tools, set import behavior, and write the final migration package JSON.
|
||||
|
||||
Then copy the generated JSON package to the target environment and import it:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask import-app-migration \
|
||||
--input migration-data-20260528-120000.json \
|
||||
--target-tenant "production Workspace"
|
||||
```
|
||||
|
||||
This wizard + import combination is the recommended migration workflow because the wizard shows the available apps and tools, discovers app dependencies, summarizes the package before writing it, and uses the same export service as scripted export.
|
||||
|
||||
## Wizard Command
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask app-migration-wizard
|
||||
```
|
||||
|
||||
The wizard has no CLI options. Its interactive inputs are:
|
||||
|
||||
- `Source tenant`: the source workspace to export from. Select one tenant by number.
|
||||
- `App selection`: apps to export. Supported app types are `workflow` and `advanced-chat`. Enter `all`, one number, or comma-separated numbers.
|
||||
- `Automatically export tools referenced by selected apps?`: recommended `yes`. This scans selected app graphs and automatically includes referenced custom API tools, workflow tools, and MCP tool references.
|
||||
- `Export additional tools manually?`: optional. Use this when you need to migrate tools that are not referenced by the selected apps.
|
||||
- `Include secrets in output JSON?`: default `no`. When `no`, workflow/app DSL secrets are omitted or masked, API tool credentials are omitted, and MCP providers are exported as dependency metadata only. When `yes`, treat the output JSON as sensitive.
|
||||
- `Create or reuse app API tokens during import?`: default `no`. When `yes`, import creates an app API token for imported apps that have none, or reuses an existing token.
|
||||
- `Import ID strategy`: `preserve-id` or `generate-new-id`. Default is `preserve-id`.
|
||||
- `Import conflict strategy`: `fail`, `skip`, or `update`. Wizard default is `update`.
|
||||
- `Output path`: output migration package path. The default is `migration-data-YYYYMMDD-HHMMSS.json`.
|
||||
- `Overwrite existing output`: shown only if the selected output file already exists.
|
||||
- `Write migration package?`: final confirmation after the wizard prints the summary.
|
||||
|
||||
## Import Command
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask import-app-migration --input migration-package.json --target-tenant "target Workspace"
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--input`: required. Path to the migration package JSON generated by the wizard or scripted export.
|
||||
- `--target-tenant`: optional only when package metadata already contains a target tenant. Target workspace name or workspace UUID. This overrides package metadata and is recommended for reusable packages.
|
||||
- `--operator-email`: optional. Email of the target-tenant account used as the import operator. If omitted, import uses the earliest owner account in the target tenant.
|
||||
- `--id-strategy`: optional override for the package import option. Allowed values:
|
||||
- `preserve-id`: keep source app/tool IDs when supported. This is the recommended default for preserving workflow references across environments.
|
||||
- `generate-new-id`: let the target environment generate new IDs and rewrite references through the migration ID mapping.
|
||||
- `--conflict-strategy`: optional override for the package import option. Allowed values:
|
||||
- `fail`: stop at the first existing target resource conflict. Previously committed resources are not rolled back.
|
||||
- `skip`: keep the existing target resource and skip importing that resource.
|
||||
- `update`: update the existing target resource in place.
|
||||
- `--create-app-api-token-on-import`: optional override. Create or reuse app API tokens during import.
|
||||
- `--no-create-app-api-token-on-import`: optional override. Do not create app API tokens during import.
|
||||
|
||||
Import prints a report that includes the resolved target tenant, operator, created/updated/skipped resources, unresolved dependencies, app API token counts, and ID mappings used to rewrite references.
|
||||
|
||||
## Optional Flow: Scripted Export
|
||||
|
||||
Scripted export is supported for automation. The recommended scripted path is:
|
||||
|
||||
1. Generate an export config template.
|
||||
2. Edit the template.
|
||||
3. Run scripted export.
|
||||
4. Import the generated package.
|
||||
|
||||
Generate the template:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask app-migration-template --output export-config.json
|
||||
```
|
||||
|
||||
Edit `export-config.json`, then run:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask export-app-migration \
|
||||
--input export-config.json \
|
||||
--output migration-package.json
|
||||
```
|
||||
|
||||
Import it:
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask import-app-migration \
|
||||
--input migration-package.json \
|
||||
--target-tenant "production Workspace"
|
||||
```
|
||||
|
||||
### Template Command
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask app-migration-template [--output export-config.json] [--overwrite]
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--output`: optional. Path to write the scripted export config JSON template. If omitted, the template is printed to stdout.
|
||||
- `--overwrite`: optional. Allows replacing an existing output file. Without this option, the command fails if `--output` already exists.
|
||||
|
||||
### Scripted Export Command
|
||||
|
||||
```bash
|
||||
cd api
|
||||
source .venv/bin/activate
|
||||
uv run flask export-app-migration --input export-config.json --output migration-package.json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--input`: required. Path to the export config JSON.
|
||||
- `--output`: required. Path to write the migration package JSON.
|
||||
- `--overwrite`: optional. Allows replacing an existing output package. Without this option, the command fails if `--output` already exists.
|
||||
|
||||
### Export Config Fields
|
||||
|
||||
The generated template uses this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_tenant": {
|
||||
"mode": "single",
|
||||
"id": "",
|
||||
"name": "admin's Workspace"
|
||||
},
|
||||
"apps": {
|
||||
"modes": ["workflow", "advanced-chat"],
|
||||
"ids": [],
|
||||
"all": true
|
||||
},
|
||||
"include_referenced_tools": true,
|
||||
"additional_tools": {
|
||||
"api_tools": [],
|
||||
"workflow_tools": [],
|
||||
"mcp_tools": []
|
||||
},
|
||||
"include_secrets": false,
|
||||
"import_options": {
|
||||
"create_app_api_token_on_import": false,
|
||||
"id_strategy": "preserve-id",
|
||||
"conflict_strategy": "fail"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `source_tenant.mode`: must be `single`.
|
||||
- `source_tenant.id`: optional source workspace UUID. Use this when workspace names are duplicated or when you want strict source selection.
|
||||
- `source_tenant.name`: required source workspace name. When `source_tenant.id` is set, the ID and name must match.
|
||||
- `apps.modes`: optional list of app modes for validation. Supported values are `workflow` and `advanced-chat`.
|
||||
- `apps.ids`: app IDs to export when `apps.all` is `false`.
|
||||
- `apps.all`: when `true`, export all supported apps in the selected source workspace. When `false`, export only `apps.ids`.
|
||||
- `include_referenced_tools`: recommended `true`. Automatically discovers tools referenced by selected workflow/chatflow apps.
|
||||
- `additional_tools.api_tools`: optional custom API tool provider names to export in addition to referenced tools.
|
||||
- `additional_tools.workflow_tools`: optional workflow tool provider IDs to export in addition to referenced tools.
|
||||
- `additional_tools.mcp_tools`: optional MCP provider IDs or server identifiers to export in addition to referenced tools.
|
||||
- `include_secrets`: default `false`. When `false`, credentials are omitted and MCP providers are recorded as dependency metadata. When `true`, custom API credentials, workflow DSL secrets, and full MCP connection data can be written to the package.
|
||||
- `import_options.create_app_api_token_on_import`: default `false`. Package-level default for import token creation.
|
||||
- `import_options.id_strategy`: package-level default ID strategy. Allowed values are `preserve-id` and `generate-new-id`.
|
||||
- `import_options.conflict_strategy`: package-level default conflict strategy. Allowed values are `fail`, `skip`, and `update`.
|
||||
|
||||
## Referenced Tools
|
||||
|
||||
Use automatic referenced-tool discovery whenever possible.
|
||||
|
||||
When enabled, Dify scans selected workflow/chatflow graphs and agent tool configs, then de-duplicates discovered custom API tools, workflow tools, and MCP tool references before export. This reduces the chance of importing an app whose workflow references missing providers.
|
||||
|
||||
Built-in and plugin tools are not serialized into the package. The migration report records them as dependency metadata; make sure the target environment has the required built-in or plugin tools installed and configured.
|
||||
|
||||
MCP providers are also dependency-only unless `include_secrets` is enabled. If MCP secrets are not exported, configure the corresponding MCP provider manually in the target workspace before running migrated workflows.
|
||||
|
||||
## Secret Handling
|
||||
|
||||
The safe default is `include_secrets: false`.
|
||||
|
||||
Only enable secret export when you have a controlled transfer path for the JSON package. With secrets enabled, the package may include API tool credentials, workflow/app DSL secret values, MCP server URLs, MCP headers, authentication data, and cached MCP tool lists.
|
||||
|
||||
## FAQ
|
||||
|
||||
### What happens when `include_secrets` is `false`?
|
||||
|
||||
This is the recommended default, but it means the migration package is intentionally incomplete for sensitive runtime configuration.
|
||||
|
||||
Important behavior:
|
||||
|
||||
- MCP tools are not exported as full tool providers. They are recorded as dependency metadata only.
|
||||
- Custom API tool credentials are not exported. The provider schema can be exported, but credentials must be configured again in the target workspace.
|
||||
- Workflow/app DSL secrets are omitted or masked. Any app variables, credentials, or secret-backed settings must be reviewed in the target workspace after import.
|
||||
- Built-in and plugin tools are never serialized as custom migration data. They are recorded as dependencies only.
|
||||
|
||||
How to handle it:
|
||||
|
||||
- Before importing, install or enable the required built-in/plugin tools in the target environment.
|
||||
- For MCP tools, manually create or configure the corresponding MCP provider in the target workspace before running migrated workflows.
|
||||
- For custom API tools, open the imported provider in the target workspace and re-enter credentials.
|
||||
- After import, review each migrated workflow/chatflow before production use. Pay special attention to tool nodes, agent tool configs, environment variables, app variables, and credential-backed settings.
|
||||
- Use the import report. Items marked `dependency-only`, `skipped`, or `unresolved` usually need manual follow-up.
|
||||
|
||||
If you need the package to carry full MCP provider configuration or credentials, rerun export with `include_secrets` set to `true` and transfer the generated JSON as sensitive data.
|
||||
|
||||
### What should I consider when `include_secrets` is `true`?
|
||||
|
||||
The package can include sensitive runtime configuration such as API tool credentials, workflow/app DSL secret values, MCP server URLs, headers, authentication data, and MCP tool lists.
|
||||
|
||||
How to handle it:
|
||||
|
||||
- Store and transfer the JSON package as a secret.
|
||||
- Delete the package after import if your security policy requires it.
|
||||
- Prefer using this only for controlled one-time migrations where manual target-side credential setup is harder than securing the package.
|
||||
|
||||
### Should I use `preserve-id` or `generate-new-id`?
|
||||
|
||||
The ID strategy controls whether imported resources keep source IDs or receive new IDs in the target environment. It applies to workflow apps and custom tool resources where the target service supports explicit IDs.
|
||||
|
||||
Use `preserve-id` by default. It is recommended for cross-environment app migration because imported apps and tools try to keep their source IDs, which makes workflow references easier to preserve.
|
||||
|
||||
Use `preserve-id` when:
|
||||
|
||||
- You are migrating between environments that are meant to mirror each other, such as staging to production.
|
||||
- You want workflow tool references and provider references to remain as stable as possible.
|
||||
- The target environment does not already contain unrelated resources with the same IDs.
|
||||
|
||||
Watch out for:
|
||||
|
||||
- If the target already has a resource with the same ID, `conflict_strategy=fail` stops the import, `skip` keeps the target resource, and `update` updates it in place.
|
||||
- If the same ID was reused for a different resource in the target environment, review carefully before using `update`.
|
||||
|
||||
Use `generate-new-id` when the target environment should keep its own local IDs.
|
||||
|
||||
Use `generate-new-id` when:
|
||||
|
||||
- The target environment already has resources that may conflict with source IDs.
|
||||
- You are importing a package as a copy rather than trying to mirror environments.
|
||||
- You want to avoid preserving source database IDs in the target environment.
|
||||
|
||||
Watch out for:
|
||||
|
||||
- Import records source-to-target ID mappings. Always review the import report's ID mappings.
|
||||
- Workflow DSL provider references are rewritten using the generated ID mapping where possible.
|
||||
- Dependencies that are metadata-only, such as MCP providers exported with `include_secrets=false`, still require manual target-side configuration.
|
||||
- Built-in/plugin tools are not remapped as migrated custom resources; the target environment must provide them separately.
|
||||
@ -147,6 +147,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -243,6 +248,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/nav-link/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -3162,6 +3172,16 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3332,6 +3352,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/blocks.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -5215,6 +5240,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
@ -5480,6 +5510,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
@ -513,12 +513,27 @@
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"line-others-dhs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-drag-handle": {
|
||||
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"line-others-dvs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-env": {
|
||||
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
|
||||
},
|
||||
"line-others-evaluation": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-global-variable": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
@ -1025,6 +1040,11 @@
|
||||
"workflow-if-else": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"workflow-input-field": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"workflow-iteration": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 277,
|
||||
"total": 281,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -340,16 +340,11 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -380,21 +375,19 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -168,6 +168,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@ -28,12 +28,16 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
appInfoActions?: AppInfoActions
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
iconType = 'app',
|
||||
appInfoActions,
|
||||
}: IAppDetailNavProps) => {
|
||||
@ -112,18 +116,20 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -152,18 +158,20 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderNavigation
|
||||
? renderNavigation(appSidebarExpand)
|
||||
: navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -0,0 +1,270 @@
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user