mirror of
https://github.com/langgenius/dify.git
synced 2026-06-01 14:38:12 +08:00
Compare commits
1 Commits
fix/cli-to
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e398db150 |
@ -209,16 +209,14 @@ class AppQueueManager(ABC):
|
||||
|
||||
def _check_for_sqlalchemy_models(self, data: Any):
|
||||
# from entity to dict or list
|
||||
match data:
|
||||
case dict():
|
||||
for value in data.values():
|
||||
self._check_for_sqlalchemy_models(value)
|
||||
case list():
|
||||
for item in data:
|
||||
self._check_for_sqlalchemy_models(item)
|
||||
case _:
|
||||
if isinstance(data, DeclarativeMeta) or hasattr(data, "_sa_instance_state"):
|
||||
raise TypeError(
|
||||
"Critical Error: Passing SQLAlchemy Model instances that"
|
||||
" cause thread safety issues is not allowed."
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
for value in data.values():
|
||||
self._check_for_sqlalchemy_models(value)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
self._check_for_sqlalchemy_models(item)
|
||||
else:
|
||||
if isinstance(data, DeclarativeMeta) or hasattr(data, "_sa_instance_state"):
|
||||
raise TypeError(
|
||||
"Critical Error: Passing SQLAlchemy Model instances that cause thread safety issues is not allowed."
|
||||
)
|
||||
|
||||
@ -1525,18 +1525,16 @@ class DatasetRetrieval:
|
||||
filters.append(json_field.like(f"%{escaped_value}", escape="\\"))
|
||||
|
||||
case "is" | "=":
|
||||
match value:
|
||||
case str():
|
||||
filters.append(json_field == value)
|
||||
case int() | float():
|
||||
filters.append(DatasetDocument.doc_metadata[metadata_name].as_float() == value)
|
||||
if isinstance(value, str):
|
||||
filters.append(json_field == value)
|
||||
elif isinstance(value, (int, float)):
|
||||
filters.append(DatasetDocument.doc_metadata[metadata_name].as_float() == value)
|
||||
|
||||
case "is not" | "≠":
|
||||
match value:
|
||||
case str():
|
||||
filters.append(json_field != value)
|
||||
case int() | float():
|
||||
filters.append(DatasetDocument.doc_metadata[metadata_name].as_float() != value)
|
||||
if isinstance(value, str):
|
||||
filters.append(json_field != value)
|
||||
elif isinstance(value, (int, float)):
|
||||
filters.append(DatasetDocument.doc_metadata[metadata_name].as_float() != value)
|
||||
|
||||
case "empty":
|
||||
filters.append(DatasetDocument.doc_metadata[metadata_name].is_(None))
|
||||
@ -1709,13 +1707,12 @@ class DatasetRetrieval:
|
||||
usage = None
|
||||
for result in invoke_result:
|
||||
text = result.delta.message.content
|
||||
match text:
|
||||
case str():
|
||||
full_text += text
|
||||
case list():
|
||||
for i in text:
|
||||
if i.data:
|
||||
full_text += i.data
|
||||
if isinstance(text, str):
|
||||
full_text += text
|
||||
elif isinstance(text, list):
|
||||
for i in text:
|
||||
if i.data:
|
||||
full_text += i.data
|
||||
|
||||
if not model:
|
||||
model = result.model
|
||||
|
||||
@ -6,7 +6,7 @@ requires-python = "~=3.12.0"
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.3.0,<7.0.0",
|
||||
"boto3>=1.43.14,<2.0.0",
|
||||
"boto3>=1.43.18,<2.0.0",
|
||||
"celery>=5.6.3,<6.0.0",
|
||||
"croniter>=6.2.2,<7.0.0",
|
||||
"dify-agent",
|
||||
@ -182,13 +182,13 @@ dev = [
|
||||
storage = [
|
||||
"azure-storage-blob>=12.29.0,<13.0.0",
|
||||
"bce-python-sdk==0.9.71",
|
||||
"cos-python-sdk-v5>=1.9.43,<2.0.0",
|
||||
"cos-python-sdk-v5>=1.9.44,<2.0.0",
|
||||
"esdk-obs-python>=3.22.2,<4.0.0",
|
||||
"google-cloud-storage>=3.10.1,<4.0.0",
|
||||
"opendal==0.46.0",
|
||||
"oss2>=2.19.1,<3.0.0",
|
||||
"supabase>=2.30.0,<3.0.0",
|
||||
"tos>=2.9.0,<3.0.0",
|
||||
"supabase>=2.30.1,<3.0.0",
|
||||
"tos>=2.9.1,<3.0.0",
|
||||
]
|
||||
|
||||
############################################################
|
||||
|
||||
@ -148,11 +148,6 @@ class _EstimateRules(BaseModel):
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
class _EstimateHierarchicalRules(_EstimateRules):
|
||||
parent_mode: Literal["full-doc", "paragraph"] | None = None
|
||||
subchunk_segmentation: _EstimateSegmentation | None = None
|
||||
|
||||
|
||||
class _SummaryIndexSettingDisabled(BaseModel):
|
||||
enable: Literal[False] = False
|
||||
|
||||
@ -208,7 +203,7 @@ class _HierarchicalProcessRule(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
mode: Literal[ProcessRuleMode.HIERARCHICAL]
|
||||
rules: _EstimateHierarchicalRules
|
||||
rules: _EstimateRules
|
||||
summary_index_setting: _SummaryIndexSetting | None = None
|
||||
|
||||
@field_validator("summary_index_setting", mode="before")
|
||||
@ -2976,10 +2971,6 @@ class DocumentService:
|
||||
process_rule_dict = validated.process_rule.model_dump(exclude_none=True)
|
||||
if validated.process_rule.mode == ProcessRuleMode.AUTOMATIC:
|
||||
process_rule_dict["rules"] = {}
|
||||
elif validated.process_rule.mode == ProcessRuleMode.HIERARCHICAL:
|
||||
rules = process_rule_dict.get("rules")
|
||||
if isinstance(rules, dict) and not rules.get("parent_mode"):
|
||||
rules["parent_mode"] = "paragraph"
|
||||
args["process_rule"] = process_rule_dict
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -1344,27 +1344,6 @@ class TestDocumentServiceEstimateValidation:
|
||||
|
||||
assert args["process_rule"]["rules"]["pre_processing_rules"] == [{"id": "remove_stopwords", "enabled": False}]
|
||||
|
||||
def test_estimate_args_validate_custom_mode_drops_hierarchical_fields(self):
|
||||
args = {
|
||||
"info_list": {"data_source_type": "upload_file"},
|
||||
"process_rule": {
|
||||
"mode": "custom",
|
||||
"rules": {
|
||||
"pre_processing_rules": [{"id": "remove_stopwords", "enabled": True}],
|
||||
"segmentation": {"separator": "\n", "max_tokens": 128},
|
||||
"parent_mode": "full-doc",
|
||||
"subchunk_segmentation": {"separator": "###", "max_tokens": 64},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DocumentService.estimate_args_validate(args)
|
||||
|
||||
assert args["process_rule"]["rules"] == {
|
||||
"pre_processing_rules": [{"id": "remove_stopwords", "enabled": True}],
|
||||
"segmentation": {"separator": "\n", "max_tokens": 128},
|
||||
}
|
||||
|
||||
def test_estimate_args_validate_requires_summary_index_provider_name(self):
|
||||
args = {
|
||||
"info_list": {"data_source_type": "upload_file"},
|
||||
@ -1381,43 +1360,6 @@ class TestDocumentServiceEstimateValidation:
|
||||
with pytest.raises(ValueError, match="Field required"):
|
||||
DocumentService.estimate_args_validate(args)
|
||||
|
||||
def test_estimate_args_validate_preserves_hierarchical_fields(self):
|
||||
args = {
|
||||
"info_list": {"data_source_type": "upload_file"},
|
||||
"process_rule": {
|
||||
"mode": "hierarchical",
|
||||
"rules": {
|
||||
"pre_processing_rules": [{"id": "remove_stopwords", "enabled": True}],
|
||||
"segmentation": {"separator": "\n", "max_tokens": 512},
|
||||
"parent_mode": "full-doc",
|
||||
"subchunk_segmentation": {"separator": "###", "max_tokens": 128},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DocumentService.estimate_args_validate(args)
|
||||
|
||||
assert args["process_rule"]["rules"]["parent_mode"] == "full-doc"
|
||||
assert args["process_rule"]["rules"]["subchunk_segmentation"] == {"separator": "###", "max_tokens": 128}
|
||||
|
||||
def test_estimate_args_validate_hierarchical_defaults_parent_mode_to_paragraph(self):
|
||||
args = {
|
||||
"info_list": {"data_source_type": "upload_file"},
|
||||
"process_rule": {
|
||||
"mode": "hierarchical",
|
||||
"rules": {
|
||||
"pre_processing_rules": [{"id": "remove_stopwords", "enabled": True}],
|
||||
"segmentation": {"separator": "\n", "max_tokens": 512},
|
||||
"subchunk_segmentation": {"separator": "###", "max_tokens": 128},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DocumentService.estimate_args_validate(args)
|
||||
|
||||
assert args["process_rule"]["rules"]["parent_mode"] == "paragraph"
|
||||
assert args["process_rule"]["rules"]["subchunk_segmentation"] == {"separator": "###", "max_tokens": 128}
|
||||
|
||||
|
||||
class TestDocumentServiceSaveDocumentAdditionalBranches:
|
||||
"""Additional unit tests for dataset bootstrap and process-rule branches."""
|
||||
|
||||
72
api/uv.lock
generated
72
api/uv.lock
generated
@ -595,16 +595,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.14"
|
||||
version = "1.43.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/37/2ae45d06423182b4561c03bc33494fafa21a0d1e847f0554f590e3cbbc62/boto3-1.43.18.tar.gz", hash = "sha256:33138883e984eb1937d1553da699182c8ad2099138091e885b65c9accbccea16", size = 113154, upload-time = "2026-05-29T19:33:30.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/75/fcb2c10d516496536c50e397248de42a673d8ea8137caf5b578a72b11293/boto3-1.43.18-py3-none-any.whl", hash = "sha256:7b62ce5c0a51428d692aa4f2adc9dc2a4a4c2989bf65a0a12834eeffa99b0b84", size = 140538, upload-time = "2026-05-29T19:33:27.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -627,16 +627,16 @@ bedrock-runtime = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.14"
|
||||
version = "1.43.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/6d/436d69ec484ffc43635b38d7fb7d717d38824671f10e12e77924019ca929/botocore-1.43.18.tar.gz", hash = "sha256:dc8c105351b49688c667065cd5a45fc5b9db982657cefc9e3fbfb9417a55c7df", size = 15424886, upload-time = "2026-05-29T19:33:16.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/5a/35c92c0af1514581031fe66c398b622176b3c928a6d5cf8133c7207e3bd7/botocore-1.43.18-py3-none-any.whl", hash = "sha256:e2610fce16df9f89deab5f3c163430a814e6804034eb95bef8957c8db60b7dbc", size = 15106258, upload-time = "2026-05-29T19:33:11.18Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1049,7 +1049,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cos-python-sdk-v5"
|
||||
version = "1.9.43"
|
||||
version = "1.9.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crcmod" },
|
||||
@ -1058,9 +1058,9 @@ dependencies = [
|
||||
{ name = "six" },
|
||||
{ name = "xmltodict" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/73/3d5321fa6c0fe14ababd5e4a8d02941785b54a9b1ba4e99336b227cba223/cos_python_sdk_v5-1.9.43.tar.gz", hash = "sha256:ff661561686356f4cff02af03a63eca27607edef2edd233f9cdcd1ca2125357b", size = 103216, upload-time = "2026-05-13T12:01:53.765Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/c8/5cc0d0bd4b4bd9006976408bf390927fe51138658235c512a76799932de0/cos_python_sdk_v5-1.9.44.tar.gz", hash = "sha256:2ab403f3b64efdbb9a1984ad3e4381bd7f0755d1064148aeb25269bd757b49ab", size = 103461, upload-time = "2026-05-29T03:50:19.02Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/dd/b6cbe0ddd04c0543195e089bd962f5e890218e621dfb652781997860eda5/cos_python_sdk_v5-1.9.43-py3-none-any.whl", hash = "sha256:2623db720d9d1aac01faf5ad5a422008a4a0475a852c8413a56b0a8415f647aa", size = 98826, upload-time = "2026-05-13T12:01:51.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ed/4eabca16f3f9049ac0ee5bd17ef42709a6ede659e81e97f74e77a1aae30b/cos_python_sdk_v5-1.9.44-py3-none-any.whl", hash = "sha256:584a97ecd5e9b15b04a1e511e6dd0ea4eca00ee87fe50aa9a3c91ce716149bcb", size = 99127, upload-time = "2026-05-29T03:50:17.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1612,7 +1612,7 @@ requires-dist = [
|
||||
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
|
||||
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
|
||||
{ name = "bleach", specifier = ">=6.3.0,<7.0.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.14,<2.0.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.18,<2.0.0" },
|
||||
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
|
||||
{ name = "dify-agent", editable = "../dify-agent" },
|
||||
@ -1718,13 +1718,13 @@ dev = [
|
||||
storage = [
|
||||
{ name = "azure-storage-blob", specifier = ">=12.29.0,<13.0.0" },
|
||||
{ name = "bce-python-sdk", specifier = "==0.9.71" },
|
||||
{ name = "cos-python-sdk-v5", specifier = ">=1.9.43,<2.0.0" },
|
||||
{ name = "cos-python-sdk-v5", specifier = ">=1.9.44,<2.0.0" },
|
||||
{ name = "esdk-obs-python", specifier = ">=3.22.2,<4.0.0" },
|
||||
{ name = "google-cloud-storage", specifier = ">=3.10.1,<4.0.0" },
|
||||
{ name = "opendal", specifier = "==0.46.0" },
|
||||
{ name = "oss2", specifier = ">=2.19.1,<3.0.0" },
|
||||
{ name = "supabase", specifier = ">=2.30.0,<3.0.0" },
|
||||
{ name = "tos", specifier = ">=2.9.0,<3.0.0" },
|
||||
{ name = "supabase", specifier = ">=2.30.1,<3.0.0" },
|
||||
{ name = "tos", specifier = ">=2.9.1,<3.0.0" },
|
||||
]
|
||||
tools = [
|
||||
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
|
||||
@ -4859,7 +4859,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "postgrest"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecation" },
|
||||
@ -4867,9 +4867,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/7c/54e7be05adc9fd6fd98dc572ddfc8982d45bec314a55711e37277d440698/postgrest-2.30.0.tar.gz", hash = "sha256:4f89eec56ce605ab6fbddd9b96d526a9bb44962796d44a5d85cb77640eb766c3", size = 14430, upload-time = "2026-05-06T17:35:21.559Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/9b/7d23f94d021c8adb8e8726b6da7af87265e92af0a982319af8931906a000/postgrest-2.30.1.tar.gz", hash = "sha256:e013e6fa450ca5305a46d4fd949c3982b6e1f7f94ab916c9c6625faf18724055", size = 14428, upload-time = "2026-05-29T14:24:57.356Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/aa/ff2e09f99f95ea96fddeb373646bf907dd89a24fc00b5d38e5674ca7c9ca/postgrest-2.30.0-py3-none-any.whl", hash = "sha256:30631e7993da542419f4217cf3b60aa641084731ea15e66a18526a3a52e40a7d", size = 23108, upload-time = "2026-05-06T17:35:20.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/94/0cd4e9950359a1727cd1d24c90b1f37a1b4c14df89bd7ddc75c2aa06501f/postgrest-2.30.1-py3-none-any.whl", hash = "sha256:4580124fd0ede43e19a859791418e437257938dc2f23aab9a3e68638da5f4a05", size = 23106, upload-time = "2026-05-29T14:24:56.321Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5805,16 +5805,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "realtime"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/a2/0328d49d3b5fb427068e9200e7de5b0d708d021a1ad98d004bc685d2529e/realtime-2.30.0.tar.gz", hash = "sha256:7aa593da52ed5f92c34ec4e50e32043afa62f219c94f717ad64a66ab0ef9f1ba", size = 18718, upload-time = "2026-05-06T17:35:23.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/87/5371d6908e491b093ca35770fe105ba28f83da6fef1f9d819e133313464b/realtime-2.30.1.tar.gz", hash = "sha256:c7edd03b34fdc5a57ef5035460000c405d37498aaa687685b998ba55d13f8a9f", size = 18716, upload-time = "2026-05-29T14:24:59.092Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/75/1b2cfc949595e22d8c05a2aa2cfc222921f7f94177d7e8a90542f3f73b33/realtime-2.30.0-py3-none-any.whl", hash = "sha256:7c93b63d2cf99aa1da4fa8826b03b00cd32f7b38abb27ff47b19eb5dcb5707c6", size = 22376, upload-time = "2026-05-06T17:35:22.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9e/2a745026e552ef05c5b16ae6e1ad4dc44c9f017bcd021cd54786a8ce2226/realtime-2.30.1-py3-none-any.whl", hash = "sha256:c9f1d4657b8cf2fa7259963021c1e87a929290cd239ca34eb22977c27b705802", size = 22374, upload-time = "2026-05-29T14:24:58.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -5996,14 +5996,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/ec/7c692cde9125b77e84b307354d4fb705f98b8ccad59a036d5957ca75bfc3/s3transfer-0.17.0.tar.gz", hash = "sha256:9edeb6d1c3c2f89d6050348548834ad8289610d886e5bf7b7207728bd43ce33a", size = 155337, upload-time = "2026-04-29T22:07:36.33Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/72/c6c32d2b657fa3dad1de340254e14390b1e334ce38268b7ad51abda3c8c2/s3transfer-0.17.0-py3-none-any.whl", hash = "sha256:ce3801712acf4ad3e89fb9990df97b4972e93f4b3b0004d214be5bce12814c20", size = 86811, upload-time = "2026-04-29T22:07:34.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6296,7 +6296,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "storage3"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecation" },
|
||||
@ -6305,9 +6305,9 @@ dependencies = [
|
||||
{ name = "pyiceberg" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/b2/6df208d64630744704d00f2c07197170390d6b4d0098617740f6a7a4fa98/storage3-2.30.0.tar.gz", hash = "sha256:b74e3cac149f2c0553dcb5f4d55d8c35d420d88183a1a2df77727d482665972b", size = 20162, upload-time = "2026-05-06T17:35:25.71Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/92/b2f45e8d521c802713d6baf898383bd45d053dbf35e7796e44937a284948/storage3-2.30.1.tar.gz", hash = "sha256:5a52784948dbe5ff8d84da5c220ed55a9a9d45bbbaa07b62bdf7dc933c9910e0", size = 20163, upload-time = "2026-05-29T14:25:00.877Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/91/5c/bb8c8cc448cfae671c4ffee67f3651892ea59b341f27bed54666190eb8ef/storage3-2.30.0-py3-none-any.whl", hash = "sha256:2bd23a34011c018bd9c130d8a70a09ebd060ae80d946c6204a6fc08161ad728d", size = 28284, upload-time = "2026-05-06T17:35:24.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/77/fb49f035001e15b5e556977eb32dec84ec88f8a61e2590b5d19ff4cdbef6/storage3-2.30.1-py3-none-any.whl", hash = "sha256:177aef2005650a9a4a1077d0c2207c36c9d06dcce773b732ec3ad3f07e27b273", size = 28284, upload-time = "2026-05-29T14:24:59.848Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6333,7 +6333,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "supabase"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@ -6344,37 +6344,37 @@ dependencies = [
|
||||
{ name = "supabase-functions" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/a6/d2b17021c2db1a9d219c383e0762ac03a62b25468e61ab126b6b561c2f21/supabase-2.30.0.tar.gz", hash = "sha256:efdba41d474038ed220736ba4e64946df56043057ad785c4c3499d27e459975c", size = 9689, upload-time = "2026-05-06T17:35:27.781Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/ea/8c9f9964612c2a811ad50f46c2da38834152846d0467ad38cda56bfdd9a8/supabase-2.30.1.tar.gz", hash = "sha256:a812c9316e103bec83f2883aa5c046f26fba3e808cce3129f405c7837bf9541f", size = 9691, upload-time = "2026-05-29T14:25:02.568Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/82/d213be7d0ce0bb18018744c0ee38ba0d6648d41dbc46ac8558cffe80541f/supabase-2.30.0-py3-none-any.whl", hash = "sha256:f9b259194554f7bfd2dca6c23261f2df588016ca18b18e774f4d85bc941edb03", size = 16634, upload-time = "2026-05-06T17:35:26.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c4/e3ddf6762a2033c0a902306d8f47dca04599037705ef0c09be8683006f74/supabase-2.30.1-py3-none-any.whl", hash = "sha256:71027dff498f8f9a2602a34ce7cb633d3429d2e3d5a3c261790c72a49c69b669", size = 16634, upload-time = "2026-05-29T14:25:01.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supabase-auth"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/8a/48bbbe0b6703d0670b67e45b90d6a791fd01aace67443d286f760bf48895/supabase_auth-2.30.0.tar.gz", hash = "sha256:6138a53a306a95ed59c03d4e4975469dfc3343a0ade33cc4b37e4ef967ad83f8", size = 39135, upload-time = "2026-05-06T17:35:30.371Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/9a/08f49a89176ca7fd288b6293c043345ae45d9ed601f5c78bc1bfcee1e4ed/supabase_auth-2.30.1.tar.gz", hash = "sha256:13f094121d858e9344dd6a05dd584778f62f416b7f09fda5b4b1471e4451b596", size = 39152, upload-time = "2026-05-29T14:25:04.507Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/40/a99cb4373353bcbf302d962e51da9eac78b3b0f257eb0362c0852b1667f4/supabase_auth-2.30.0-py3-none-any.whl", hash = "sha256:e85e1f51ec0de2172c3a2a8514205f71731a9914f9a770ed199ac0cf054bc82c", size = 48352, upload-time = "2026-05-06T17:35:28.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/3c/f261dcfcdee60b224cea8e2ebe6e410048f3043c5f877cef17738c6a5fac/supabase_auth-2.30.1-py3-none-any.whl", hash = "sha256:bd6bdb3cd6ecaa72acf92f3211619015e02425b9323b3bc4568af598f8f556ec", size = 48370, upload-time = "2026-05-29T14:25:03.314Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "supabase-functions"
|
||||
version = "2.30.0"
|
||||
version = "2.30.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "strenum" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/e6/5cd8559ec2bb332e6027840c1be292f9989c2fc7b47bf40800aec5586791/supabase_functions-2.30.0.tar.gz", hash = "sha256:025acfd25f1c000ba43d0f7b8e366b0d2e9dfc784b842528e21973eb33006113", size = 4683, upload-time = "2026-05-06T17:35:32.246Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/08/6d00627295f196c5f4c6a70945a3e7339712321511d1377d6262db4dff89/supabase_functions-2.30.1.tar.gz", hash = "sha256:c09f8b46d3cf85713e2d8bf879dd77905ceb1d73508dbc404cc35cb36e75fb42", size = 4686, upload-time = "2026-05-29T14:25:06.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/da/9dedab32775df04cc22ca72f194b78e895d940f195bed3e02882a65daa9b/supabase_functions-2.30.0-py3-none-any.whl", hash = "sha256:92419459f102767b954cd034856e4ded8e34c78660b32442d66c8b2899c68011", size = 8803, upload-time = "2026-05-06T17:35:31.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/9f/4058525e93e6fdcd5c7f216acefe956a3a16001c3c2a1d01b205c7b11eb6/supabase_functions-2.30.1-py3-none-any.whl", hash = "sha256:8d756b5bb5444c1edd43f2f44bb7698ddb242a8bc9f9eb2f8fa04ba55799e0ba", size = 8804, upload-time = "2026-05-29T14:25:05.197Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -6564,7 +6564,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tos"
|
||||
version = "2.9.0"
|
||||
version = "2.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crcmod" },
|
||||
@ -6574,7 +6574,7 @@ dependencies = [
|
||||
{ name = "six" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/b3/13451226f564f88d9db2323e9b7eabcced792a0ad5ee1e333751a7634257/tos-2.9.0.tar.gz", hash = "sha256:861cfc348e770f099f911cb96b2c41774ada6c9c51b7a89d97e0c426074dd99e", size = 157071, upload-time = "2026-01-06T04:13:08.921Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/42/079680659e1f6c20f7b837e6b13f16d4c1c389889071f49c18baea9ac2ac/tos-2.9.1.tar.gz", hash = "sha256:06a5cc095d5b3f0e52b04aee8f7e60f8ddcf0c94c4408213e3485e40070d54ef", size = 163709, upload-time = "2026-05-26T03:29:27.747Z" }
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
|
||||
69
cli/ARD.md
69
cli/ARD.md
@ -47,12 +47,13 @@ Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`.
|
||||
|
||||
**3. Optional files — add as needed**
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| `handlers.ts` | Output types implementing `FormattedPrintable` or `TablePrintable` |
|
||||
| `payload-shape.ts` | Response type narrowing/transformation |
|
||||
| `run.test.ts` | Behavior tests against `run.ts` |
|
||||
| `guide.ts` | Agent onboarding text — exports `agentGuide` string |
|
||||
| File | Purpose |
|
||||
| ------------------ | --------------------------------------------------- |
|
||||
| `handlers.ts` | Output format handlers (text, table, etc.) |
|
||||
| `print-flags.ts` | `--output` flag → printer resolution |
|
||||
| `payload-shape.ts` | Response type narrowing/transformation |
|
||||
| `run.test.ts` | Behavior tests against `run.ts` |
|
||||
| `guide.ts` | Agent onboarding text — exports `agentGuide` string |
|
||||
|
||||
**4. Checklist**
|
||||
|
||||
@ -162,28 +163,21 @@ Only override `enabled` for intentional suppression (e.g., tests using `bufferSt
|
||||
|
||||
---
|
||||
|
||||
## Output protocol
|
||||
## Printer chain
|
||||
|
||||
Output rendering separated from data fetching via protocol objects.
|
||||
Output rendering separated from data fetching.
|
||||
|
||||
- Data classes implement `TablePrintable` or `FormattedPrintable` from `src/framework/output`.
|
||||
- Streaming commands implement `StreamPrinter` from `src/framework/stream`.
|
||||
- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; the base class calls `stringifyOutput()`.
|
||||
- Commands that write incrementally (streaming) write directly from the strategy via `deps.io.out.write(stringifyOutput(...))`.
|
||||
1. `run.ts` returns string — rendered result.
|
||||
1. `handlers.ts` defines format handlers (`TextHandler`, `TableHandler`, etc.).
|
||||
1. `print-flags.ts` maps `--output` value to correct handler.
|
||||
|
||||
```typescript
|
||||
// handlers.ts — implement the protocol on the data object
|
||||
export class MyListOutput implements TablePrintable {
|
||||
tableColumns() { return COLUMNS }
|
||||
tableRows() { return this.rows.map(r => r.tableRow()) }
|
||||
json() { return { items: this.rows.map(r => r.json()) } }
|
||||
}
|
||||
|
||||
// index.ts — wrap and return
|
||||
return table({ format: flags.output, data: result })
|
||||
// run.ts
|
||||
const printer = new AppPrintFlags().toPrinter(format)
|
||||
return printer.print(data)
|
||||
```
|
||||
|
||||
New output format: add to `OutputFormat` in `framework/output.ts` and handle in `stringifyOutput`. Never add `if (format === 'json')` branches in `run.ts` or handlers.
|
||||
New output format: implement handler interface, register in `print-flags.ts`. Never add `if (format === 'json')` branches in `run.ts`.
|
||||
|
||||
---
|
||||
|
||||
@ -196,11 +190,14 @@ export type RunStrategy = {
|
||||
execute: (ctx: RunContext) => Promise<void>
|
||||
}
|
||||
|
||||
const blocking = new BlockingStrategy()
|
||||
const streamingText = new StreamingTextStrategy()
|
||||
const streamingStructured = new StreamingStructuredStrategy()
|
||||
|
||||
export function pickStrategy(isText: boolean, livePrint: boolean): RunStrategy {
|
||||
return isText && livePrint ? streamingText : streamingStructured
|
||||
export function pickStrategy(useStream: boolean, isText: boolean): RunStrategy {
|
||||
if (!useStream)
|
||||
return blocking
|
||||
return isText ? streamingText : streamingStructured
|
||||
}
|
||||
```
|
||||
|
||||
@ -332,17 +329,17 @@ Repo runs `@antfu/eslint-config` + perfectionist + unicorn.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Pattern | Do instead |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format |
|
||||
| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `DifyCommand.catch()` handles |
|
||||
| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` |
|
||||
| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects |
|
||||
| Long positional arg lists | Options struct |
|
||||
| `Record<string, Strategy>` dispatch map | Named singletons + picker function |
|
||||
| `src/framework/` import in `run.ts`, `api/`, or `auth/` | Framework imports belong in `index.ts`, `handlers.ts`, and strategies only |
|
||||
| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` |
|
||||
| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` |
|
||||
| New dependency without approval | Check first |
|
||||
| Pattern | Do instead |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format |
|
||||
| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `DifyCommand.catch()` handles |
|
||||
| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` |
|
||||
| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects |
|
||||
| Long positional arg lists | Options struct |
|
||||
| `Record<string, Strategy>` dispatch map | Named singletons + picker function |
|
||||
| `src/framework/` import in `run.ts` | Keep framework imports in `index.ts` only |
|
||||
| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` |
|
||||
| `console.log` in `src/` | Return string from `run.ts`; write in `index.ts` |
|
||||
| New dependency without approval | Check first |
|
||||
|
||||
[`docs/specs/`]: docs/specs/
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import antfu, { GLOB_MARKDOWN } from '@antfu/eslint-config'
|
||||
import md from 'eslint-markdown'
|
||||
import markdownPreferences from 'eslint-plugin-markdown-preferences'
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
ignores: original => [
|
||||
'context/**',
|
||||
'docs/**',
|
||||
'dist/**',
|
||||
'coverage/**',
|
||||
...original,
|
||||
],
|
||||
typescript: {
|
||||
overrides: {
|
||||
'ts/consistent-type-definitions': ['error', 'type'],
|
||||
'ts/no-explicit-any': 'error',
|
||||
'ts/no-redeclare': 'off',
|
||||
},
|
||||
erasableOnly: true,
|
||||
},
|
||||
test: {
|
||||
overrides: {
|
||||
'test/prefer-lowercase-title': 'off',
|
||||
},
|
||||
},
|
||||
stylistic: {
|
||||
overrides: {
|
||||
'antfu/top-level-function': 'off',
|
||||
},
|
||||
},
|
||||
e18e: false,
|
||||
},
|
||||
markdownPreferences.configs.standard,
|
||||
{
|
||||
files: [GLOB_MARKDOWN],
|
||||
plugins: { md },
|
||||
rules: {
|
||||
'md/no-url-trailing-slash': 'error',
|
||||
'markdown-preferences/prefer-link-reference-definitions': [
|
||||
'error',
|
||||
{
|
||||
minLinks: 1,
|
||||
},
|
||||
],
|
||||
'markdown-preferences/ordered-list-marker-sequence': [
|
||||
'error',
|
||||
{ increment: 'never' },
|
||||
],
|
||||
'markdown-preferences/definitions-last': 'error',
|
||||
'markdown-preferences/sort-definitions': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'node/prefer-global/process': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['../**', './*/**', '..'],
|
||||
message: 'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -1,202 +1,131 @@
|
||||
import type { AccountContext } from './hosts'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts'
|
||||
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts'
|
||||
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
describe('HostsBundleSchema', () => {
|
||||
it('parses a minimal logged-out bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({})
|
||||
expect(parsed.current_host).toBe('')
|
||||
expect(parsed.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
it('parses a logged-in keychain bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
expect(parsed.token_storage).toBe('keychain')
|
||||
expect(parsed.tokens).toBeUndefined()
|
||||
})
|
||||
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
it('parses a logged-in file bundle with bearer', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_xxx' },
|
||||
})
|
||||
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
it('keeps available_workspaces when provided', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
available_workspaces: [
|
||||
{ id: 'a', name: 'A', role: 'owner' },
|
||||
{ id: 'b', name: 'B', role: 'member' },
|
||||
],
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
expect(parsed.available_workspaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields on parse', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
future_field: 42,
|
||||
token_storage: 'file',
|
||||
})
|
||||
expect(parsed.current_host).toBe('cloud.dify.ai')
|
||||
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
describe('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns an empty registry when nothing saved', () => {
|
||||
const reg = Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
it('returns undefined when nothing was saved', () => {
|
||||
expect(loadHosts()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips a populated registry', () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
const loaded = Registry.load()
|
||||
it('round-trips a fully-populated bundle', () => {
|
||||
saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(loaded?.scheme).toBe('https')
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.workspace?.id).toBe('ws-1')
|
||||
expect(loaded?.available_workspaces).toHaveLength(2)
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
expect(loaded?.token_id).toBe('tok_xyz')
|
||||
})
|
||||
|
||||
it('round-trips a file-mode bundle with bearer token', () => {
|
||||
saveHosts({
|
||||
current_host: 'self.example.com',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(loaded?.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('overwrites previous bundle on save', () => {
|
||||
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
|
||||
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('new.example.com')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('rejects invalid input at save time', () => {
|
||||
expect(() => saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'cloud',
|
||||
} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getHostStore, tokenKey } from '@/store/manager'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
@ -27,152 +25,42 @@ export const ExternalSubjectSchema = z.object({
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
export const TokensSchema = z.object({
|
||||
bearer: z.string(),
|
||||
})
|
||||
export type Tokens = z.infer<typeof TokensSchema>
|
||||
|
||||
export const HostsBundleSchema = z.object({
|
||||
current_host: z.string().default(''),
|
||||
scheme: z.string().optional(),
|
||||
account: AccountSchema.optional(),
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
available_workspaces: z.array(WorkspaceSchema).optional(),
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
tokens: TokensSchema.optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
export type HostsBundle = z.infer<typeof HostsBundleSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
export function loadHosts(): HostsBundle | undefined {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return undefined
|
||||
return HostsBundleSchema.parse(raw)
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
export function saveHosts(bundle: HostsBundle): void {
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
getHostStore().setTyped(validated)
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static load(): Registry {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
const email = entry.current_account
|
||||
if (email === undefined || email === '')
|
||||
return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
this.save()
|
||||
}
|
||||
|
||||
save(): void {
|
||||
getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
export function clearLocal(bundle: HostsBundle, store: Store): void {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
store.unset(tokenKey(bundle.current_host, accountId))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
getHostStore().rm()
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { Command } from '@/framework/command'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
import { loadHosts } from '@/auth/hosts'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { loadNudgeStore } from '@/cache/nudge-store'
|
||||
import { getEnv } from '@/env/registry'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { formatErrorForCli } from '@/errors/format'
|
||||
import { createClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme } from '@/util/host'
|
||||
import { versionInfo } from '@/version/info'
|
||||
@ -19,9 +19,7 @@ import { maybeNudgeCompat } from '@/version/nudge'
|
||||
import { resolveRetryAttempts } from './global-flags'
|
||||
|
||||
export type AuthedContext = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -39,30 +37,28 @@ export async function buildAuthedContext(
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
const bundle = loadHosts()
|
||||
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
const err = new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createClient({ host, bearer, retryAttempts })
|
||||
const host = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({
|
||||
flag: opts.retryFlag,
|
||||
env: getEnv,
|
||||
})
|
||||
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
return { bundle, http, host, io, cache }
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { saveHosts } from '@/auth/hosts'
|
||||
import { createClient } from '@/http/client'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { ENV_CONFIG_DIR, resolveConfigDir } from '@/store/dir'
|
||||
import { tokenKey } from '@/store/manager'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices'
|
||||
@ -30,21 +30,20 @@ class MemStore implements Store {
|
||||
}
|
||||
}
|
||||
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: tokenId,
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
reg.setAccount(email)
|
||||
const active = reg.resolveActive()!
|
||||
return { reg, active }
|
||||
}
|
||||
}
|
||||
|
||||
describe('runDevicesList', () => {
|
||||
@ -59,7 +58,7 @@ describe('runDevicesList', () => {
|
||||
it('table: marks current with *', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('DEVICE')
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
@ -72,12 +71,20 @@ describe('runDevicesList', () => {
|
||||
it('json: emits PaginationEnvelope unchanged', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.page).toBe(1)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
expect((parsed.data as unknown[]).length).toBe(3)
|
||||
})
|
||||
|
||||
it('not-logged-in: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesList({ io, bundle: undefined, http }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runDevicesRevoke', () => {
|
||||
@ -102,12 +109,12 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact device_label: revokes one + leaves local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -115,30 +122,30 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact id: revokes one', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: unique match revokes', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: ambiguous throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -146,10 +153,10 @@ describe('runDevicesRevoke', () => {
|
||||
it('no match throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -157,33 +164,31 @@ describe('runDevicesRevoke', () => {
|
||||
it('--all: revokes everything except current', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, all: true })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
it('revoking current session clears token and removes context from registry', async () => {
|
||||
it('revoking current id clears local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
const saved = Registry.load()
|
||||
expect(saved?.hosts[mock.url]).toBeUndefined()
|
||||
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import { clearLocal } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '@/limit/limit'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly tokenId: string
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
@ -21,6 +23,7 @@ export type DevicesListOptions = {
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -35,7 +38,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
@ -69,10 +72,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -80,6 +83,7 @@ export type DevicesRevokeOptions = {
|
||||
|
||||
export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const b = requireLogin(opts.bundle)
|
||||
if (!opts.all && (opts.target === undefined || opts.target === '')) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
@ -90,7 +94,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '')
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
@ -99,12 +103,25 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit)
|
||||
opts.reg.forget(opts.active, opts.store)
|
||||
if (selfHit) {
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(b, tokens)
|
||||
}
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
|
||||
function requireLogin(b: HostsBundle | undefined): HostsBundle {
|
||||
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export type PickResult = {
|
||||
ids: readonly string[]
|
||||
selfHit: boolean
|
||||
|
||||
@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
tokenId: ctx.active.ctx.token_id ?? '',
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
|
||||
@ -26,9 +26,7 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runDevicesRevoke({
|
||||
io: ctx.io,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
store: ctx.store,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import type { TableCell, TableColumn } from '@/framework/output'
|
||||
|
||||
const ACTIVE_MARKER = '*'
|
||||
|
||||
const LIST_COLUMNS: readonly TableColumn[] = [
|
||||
{ name: 'HOST', priority: 0 },
|
||||
{ name: 'ACCOUNT', priority: 0 },
|
||||
{ name: 'ACTIVE', priority: 0 },
|
||||
]
|
||||
|
||||
export class ContextRow {
|
||||
readonly host: string
|
||||
readonly account: string
|
||||
readonly displayName: string
|
||||
readonly active: boolean
|
||||
|
||||
constructor(host: string, account: string, displayName: string, active: boolean) {
|
||||
this.host = host
|
||||
this.account = account
|
||||
this.displayName = displayName
|
||||
this.active = active
|
||||
}
|
||||
|
||||
tableRow(): readonly TableCell[] {
|
||||
const accountCell = this.displayName ? `${this.account} (${this.displayName})` : this.account
|
||||
return [this.host, accountCell, this.active ? ACTIVE_MARKER : '']
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.account
|
||||
}
|
||||
|
||||
json() {
|
||||
return {
|
||||
host: this.host,
|
||||
account: this.account,
|
||||
name: this.displayName,
|
||||
active: this.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextListOutput {
|
||||
readonly rows: readonly ContextRow[]
|
||||
|
||||
constructor(rows: readonly ContextRow[]) {
|
||||
this.rows = rows
|
||||
}
|
||||
|
||||
tableColumns(): readonly TableColumn[] {
|
||||
return LIST_COLUMNS
|
||||
}
|
||||
|
||||
tableRows(): readonly (readonly TableCell[])[] {
|
||||
return this.rows.map(r => r.tableRow())
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this.rows.map(r => r.name()).join('\n')
|
||||
}
|
||||
|
||||
json() {
|
||||
return { contexts: this.rows.map(r => r.json()) }
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { OutputFormat, table } from '@/framework/output'
|
||||
import { runAuthList } from './list'
|
||||
|
||||
export default class AuthList extends DifyCommand {
|
||||
static override description = 'List all authenticated contexts (host + account pairs)'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> auth list',
|
||||
'<%= config.bin %> auth list -o json',
|
||||
'<%= config.bin %> auth list -o name',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
output: Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(AuthList, argv)
|
||||
const reg = Registry.load()
|
||||
const result = runAuthList(reg)
|
||||
return table({ format: flags.output, data: result })
|
||||
}
|
||||
}
|
||||
@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { stringifyOutput, table } from '@/framework/output'
|
||||
import { runAuthList } from './list'
|
||||
|
||||
function twoHostReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'keychain',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'alice@corp.com',
|
||||
accounts: {
|
||||
'alice@corp.com': {
|
||||
account: { id: 'acct-1', email: 'alice@corp.com', name: 'Alice' },
|
||||
},
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-2', email: 'bob@corp.com', name: 'Bob' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'other.dify.ai': {
|
||||
current_account: 'admin@other.com',
|
||||
accounts: {
|
||||
'admin@other.com': {
|
||||
account: { id: 'acct-3', email: 'admin@other.com', name: 'Admin' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('runAuthList', () => {
|
||||
it('returns all host+account pairs', () => {
|
||||
const result = runAuthList(twoHostReg())
|
||||
expect(result.rows).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks only the active context', () => {
|
||||
const result = runAuthList(twoHostReg())
|
||||
const active = result.rows.filter(r => r.active)
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0]!.host).toBe('cloud.dify.ai')
|
||||
expect(active[0]!.account).toBe('alice@corp.com')
|
||||
})
|
||||
|
||||
it('table: renders HOST ACCOUNT ACTIVE header', () => {
|
||||
const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) }))
|
||||
expect(out).toMatch(/HOST\s+ACCOUNT\s+ACTIVE/)
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('alice@corp.com')
|
||||
expect(out).toContain('other.dify.ai')
|
||||
})
|
||||
|
||||
it('table: marks active row with *', () => {
|
||||
const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) }))
|
||||
const lines = out.trim().split('\n')
|
||||
const activeLine = lines.find(l => l.includes('alice@corp.com'))!
|
||||
expect(activeLine).toContain('*')
|
||||
const inactiveLine = lines.find(l => l.includes('bob@corp.com'))!
|
||||
expect(inactiveLine).not.toContain('*')
|
||||
})
|
||||
|
||||
it('json: emits { contexts: [...] }', () => {
|
||||
const out = stringifyOutput(table({ format: 'json', data: runAuthList(twoHostReg()) }))
|
||||
const parsed = JSON.parse(out) as { contexts: Array<{ host: string, account: string, active: boolean }> }
|
||||
expect(parsed.contexts).toHaveLength(3)
|
||||
const activeCtx = parsed.contexts.find(c => c.active)!
|
||||
expect(activeCtx.host).toBe('cloud.dify.ai')
|
||||
expect(activeCtx.account).toBe('alice@corp.com')
|
||||
})
|
||||
|
||||
it('name: emits account emails one per line', () => {
|
||||
const out = stringifyOutput(table({ format: 'name', data: runAuthList(twoHostReg()) }))
|
||||
const lines = out.trim().split('\n').sort()
|
||||
expect(lines).toContain('alice@corp.com')
|
||||
expect(lines).toContain('admin@other.com')
|
||||
expect(lines).toContain('bob@corp.com')
|
||||
})
|
||||
|
||||
it('table: shows email (Name) when display name present', () => {
|
||||
const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) }))
|
||||
expect(out).toContain('alice@corp.com (Alice)')
|
||||
})
|
||||
|
||||
it('table: shows email only when display name absent', () => {
|
||||
const reg = Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'anon@corp.com',
|
||||
accounts: {
|
||||
'anon@corp.com': { account: { id: 'x', email: 'anon@corp.com', name: '' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const out = stringifyOutput(table({ format: '', data: runAuthList(reg) }))
|
||||
expect(out).toContain('anon@corp.com')
|
||||
expect(out).not.toContain('anon@corp.com (')
|
||||
})
|
||||
|
||||
it('empty registry: returns zero rows', () => {
|
||||
const result = runAuthList(Registry.empty())
|
||||
expect(result.rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@ -1,13 +0,0 @@
|
||||
import type { Registry } from '@/auth/hosts'
|
||||
import { ContextListOutput, ContextRow } from './handlers'
|
||||
|
||||
export function runAuthList(reg: Registry): ContextListOutput {
|
||||
const rows: ContextRow[] = []
|
||||
for (const [host, entry] of Object.entries(reg.hosts)) {
|
||||
for (const [email, ctx] of Object.entries(entry.accounts)) {
|
||||
const isActive = reg.current_host === host && entry.current_account === email
|
||||
rows.push(new ContextRow(host, email, ctx.account.name, isActive))
|
||||
}
|
||||
}
|
||||
return new ContextListOutput(rows)
|
||||
}
|
||||
@ -59,7 +59,7 @@ describe('runLogin', () => {
|
||||
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -70,17 +70,16 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(bundle.account?.email).toBe('tester@dify.ai')
|
||||
expect(bundle.workspace?.id).toBe('ws-1')
|
||||
expect(bundle.available_workspaces).toHaveLength(2)
|
||||
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
|
||||
expect(stored).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
expect(hostsRaw).toContain('tester@dify.ai')
|
||||
expect(hostsRaw).not.toContain('dfoa_test')
|
||||
expect(hostsRaw).not.toContain('bearer')
|
||||
|
||||
expect(io.outBuf()).toContain('Logged in to')
|
||||
expect(io.outBuf()).toContain('tester@dify.ai')
|
||||
@ -92,7 +91,7 @@ describe('runLogin', () => {
|
||||
mock.setScenario('sso')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const reg = await runLogin({
|
||||
const bundle = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -103,11 +102,12 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(bundle.tokens?.bearer).toBe('dfoe_test')
|
||||
expect(bundle.account).toBeUndefined()
|
||||
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
|
||||
const stored = await store.get(tokenKey(bundle.current_host, 'sso@dify.ai'))
|
||||
expect(stored).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
@ -148,24 +148,6 @@ describe('runLogin', () => {
|
||||
})).rejects.toThrow(/expired/)
|
||||
})
|
||||
|
||||
it('rejects login when the account has no email', async () => {
|
||||
mock.setScenario('no-email')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
insecure: true,
|
||||
deviceLabel: 'difyctl on test',
|
||||
api: new DeviceFlowApi(createClient({ host: mock.url })),
|
||||
store: { store, mode: 'file' },
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})).rejects.toThrow(/no email/i)
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects http:// host without --insecure', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import type { Clock } from './device-flow'
|
||||
import type { CodeResponse, PollSuccess } from '@/api/oauth-device'
|
||||
import type { AccountContext, Workspace } from '@/auth/hosts'
|
||||
import type { HostsBundle, Workspace } from '@/auth/hosts'
|
||||
import type { StorageMode, Store } from '@/store/store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '@/util/browser'
|
||||
import * as os from 'node:os'
|
||||
import * as readline from 'node:readline'
|
||||
import { DeviceFlowApi } from '@/api/oauth-device'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { saveHosts } from '@/auth/hosts'
|
||||
import { createClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { startSpinner } from '@/sys/io/spinner'
|
||||
import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser'
|
||||
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '@/util/host'
|
||||
import { awaitAuthorization, realClock } from './device-flow'
|
||||
@ -31,7 +28,7 @@ export type LoginOptions = {
|
||||
readonly clock?: Clock
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const insecure = opts.insecure ?? false
|
||||
|
||||
@ -59,44 +56,22 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
|
||||
}
|
||||
|
||||
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
|
||||
let success: PollSuccess
|
||||
try {
|
||||
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
}
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.save()
|
||||
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
|
||||
saveHosts(bundle)
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return reg
|
||||
return bundle
|
||||
}
|
||||
|
||||
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
|
||||
let raw = opts.host?.trim() ?? ''
|
||||
if (raw === '') {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--host is required (no TTY)',
|
||||
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
|
||||
})
|
||||
}
|
||||
if (raw === '')
|
||||
raw = await promptHost(opts.io)
|
||||
}
|
||||
return resolveHost({ raw, insecure })
|
||||
}
|
||||
|
||||
@ -147,43 +122,50 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
|
||||
return s.workspaces?.find(w => w.id === s.default_workspace_id)
|
||||
}
|
||||
|
||||
function accountEmail(s: PollSuccess): string {
|
||||
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
|
||||
if (email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'account has no email; cannot store credential',
|
||||
hint: 'this Dify instance returned no email for the signed-in subject',
|
||||
})
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const ctx: AccountContext = {
|
||||
account: s.account
|
||||
? { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
: { id: '', email: '', name: '' },
|
||||
token_id: s.token_id,
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function applyScheme(reg: Registry, display: string, host: string): void {
|
||||
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
|
||||
const display = bareHost(host)
|
||||
let scheme: string | undefined
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
reg.setScheme(display, u.protocol.replace(':', ''))
|
||||
scheme = u.protocol.replace(':', '')
|
||||
}
|
||||
catch { /* keep scheme unset */ }
|
||||
catch { /* keep undefined */ }
|
||||
|
||||
const bundle: HostsBundle = {
|
||||
current_host: display,
|
||||
scheme,
|
||||
token_storage: mode,
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
}
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
bundle.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
}))
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
|
||||
function accountKey(b: HostsBundle): string {
|
||||
if (b.account?.id !== undefined && b.account.id !== '')
|
||||
return b.account.id
|
||||
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
|
||||
return b.external_subject.email
|
||||
return 'default'
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { loadHosts } from '@/auth/hosts'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { createClient } from '@/http/client'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { hostWithScheme } from '@/util/host'
|
||||
@ -17,21 +16,21 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const io = realStreams()
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
const bundle = loadHosts()
|
||||
|
||||
let http: KyInstance | undefined
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
if (bearer !== '') {
|
||||
http = createClient({ host: hostWithScheme(active.host, active.scheme), bearer, retryAttempts: 0 })
|
||||
}
|
||||
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
|
||||
http = createClient({
|
||||
host: hostWithScheme(bundle.current_host, bundle.scheme),
|
||||
bearer: bundle.tokens.bearer,
|
||||
retryAttempts: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, reg, http }),
|
||||
() => runLogout({ io, bundle, http }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,64 +1,145 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { saveHosts } from '@/auth/hosts'
|
||||
import { createClient } from '@/http/client'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { tokenKey } from '@/store/manager'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runLogout } from './logout'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureBundle(host: string): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
if (prevConfigDir === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seed(store: MemStore) {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
it('happy: revokes server side, clears local store + hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
seed(store)
|
||||
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
expect(io.errBuf()).toBe('')
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/i)
|
||||
it('not-logged-in: throws BaseError', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('hosts.yml absent: still completes locally + emits success', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
expect(io.errBuf()).toContain('server revoke failed')
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
bundle.tokens = { bearer: 'dfp_personal_token' }
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.errBuf()).toBe('')
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves unrelated files in configDir', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
saveHosts(bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,46 +1,54 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { Registry } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AccountSessionsClient } from '@/api/account-sessions'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { clearLocal } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getTokenStore } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
|
||||
export type LogoutOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = opts.reg
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
let revokeWarning = ''
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
|
||||
try {
|
||||
await new AccountSessionsClient(opts.http).revokeSelf()
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
await sessions.revokeSelf()
|
||||
}
|
||||
catch (err) {
|
||||
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
|
||||
}
|
||||
}
|
||||
|
||||
reg.forget(active, store)
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(bundle, tokens)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
26
cli/src/commands/auth/status/index.ts
Normal file
26
cli/src/commands/auth/status/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { loadHosts } from '@/auth/hosts'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { runStatus } from './status'
|
||||
|
||||
export default class Status extends DifyCommand {
|
||||
static override description = 'Show authentication status for the active host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> auth status',
|
||||
'<%= config.bin %> auth status -v',
|
||||
'<%= config.bin %> auth status --json',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
verbose: Flags.boolean({ char: 'v', description: 'show account/workspace ids and storage mode', default: false }),
|
||||
json: Flags.boolean({ description: 'emit JSON', default: false }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const bundle = loadHosts()
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
94
cli/src/commands/auth/status/status.test.ts
Normal file
94
cli/src/commands/auth/status/status.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runStatus } from './status'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function ssoBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-sso-1',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runStatus', () => {
|
||||
it('logged-out: prints message + throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
expect(io.outBuf()).toContain('Not logged in')
|
||||
})
|
||||
|
||||
it('logged-out json: emits {logged_in: false}', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
|
||||
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
|
||||
})
|
||||
|
||||
it('account: human compact', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
|
||||
expect(out).toContain('Workspace: Default')
|
||||
expect(out).toContain('full access')
|
||||
})
|
||||
|
||||
it('account verbose: shows ids + storage + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), verbose: true })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('Account:')
|
||||
expect(out).toContain('acct-1')
|
||||
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
|
||||
expect(out).toContain('Available: 2 workspaces')
|
||||
expect(out).toContain('Storage: keychain')
|
||||
})
|
||||
|
||||
it('sso: human compact mentions issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
|
||||
expect(out).toContain('apps:run')
|
||||
})
|
||||
|
||||
it('account json: matches schema with workspace + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.host).toBe('cloud.dify.ai')
|
||||
expect(parsed.logged_in).toBe(true)
|
||||
expect(parsed.storage).toBe('keychain')
|
||||
expect(parsed.account).toEqual({ id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' })
|
||||
expect(parsed.workspace).toEqual({ id: 'ws-1', name: 'Default', role: 'owner' })
|
||||
expect(parsed.available_workspaces_count).toBe(2)
|
||||
})
|
||||
|
||||
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.subject_type).toBe('external_sso')
|
||||
expect(parsed.subject_email).toBe('sso@dify.ai')
|
||||
expect(parsed.subject_issuer).toBe('https://issuer.example')
|
||||
expect(parsed.account).toBeUndefined()
|
||||
})
|
||||
})
|
||||
91
cli/src/commands/auth/status/status.ts
Normal file
91
cli/src/commands/auth/status/status.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
export type StatusOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly verbose?: boolean
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runStatus(opts: StatusOptions): Promise<void> {
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
|
||||
}
|
||||
else {
|
||||
opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n')
|
||||
}
|
||||
throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' })
|
||||
}
|
||||
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${renderJson(bundle)}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
|
||||
}
|
||||
|
||||
function renderHuman(b: HostsBundle, verbose: boolean): string {
|
||||
const lines: string[] = []
|
||||
if (!verbose) {
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(sub.issuer !== ''
|
||||
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
|
||||
lines.push(' Scope: apps:run')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
|
||||
if (b.workspace?.name !== undefined && b.workspace.name !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name}`)
|
||||
lines.push(' Session: Dify account — full access')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(b.current_host)
|
||||
lines.push(sub.issuer !== ''
|
||||
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
|
||||
: ` Subject: ${sub.email} (external SSO)`)
|
||||
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(b.current_host)
|
||||
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
|
||||
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(' Session: Dify account — full access (scope: full)')
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(b: HostsBundle): string {
|
||||
const out: Record<string, unknown> = {
|
||||
host: b.current_host,
|
||||
logged_in: true,
|
||||
storage: b.token_storage,
|
||||
}
|
||||
if (b.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = b.external_subject.email
|
||||
out.subject_issuer = b.external_subject.issuer
|
||||
}
|
||||
else if (b.account !== undefined) {
|
||||
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
|
||||
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
|
||||
}
|
||||
out.available_workspaces_count = b.available_workspaces?.length ?? 0
|
||||
}
|
||||
return JSON.stringify(out, null, 2)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { loadHosts } from '@/auth/hosts'
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const reg = Registry.load()
|
||||
await runWhoami({ io: realStreams(), reg, json: flags.json })
|
||||
const bundle = loadHosts()
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,82 +1,68 @@
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runWhoami } from './whoami'
|
||||
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
|
||||
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { email: 'sso@dify.ai', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
} } },
|
||||
})
|
||||
token_storage: 'keychain',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runWhoami', () => {
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
|
||||
})
|
||||
|
||||
it('prints email + name for an account', async () => {
|
||||
it('logged-out: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toContain('a@b.c')
|
||||
expect(io.outBuf()).toContain('Ann')
|
||||
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('account human: emits "email (name)"', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
|
||||
await runWhoami({ io, bundle: accountBundle() })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
|
||||
})
|
||||
|
||||
it('account human, no name: emits email only', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = accountReg()
|
||||
reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = ''
|
||||
await runWhoami({ io, reg })
|
||||
expect(io.outBuf()).toBe('a@b.c\n')
|
||||
})
|
||||
|
||||
it('emits JSON when --json', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' })
|
||||
const b = accountBundle()
|
||||
b.account!.name = ''
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai\n')
|
||||
})
|
||||
|
||||
it('account json: emits {id, email, name}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
await runWhoami({ io, bundle: accountBundle(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
id: 'acct-1',
|
||||
email: 'a@b.c',
|
||||
name: 'Ann',
|
||||
email: 'tester@dify.ai',
|
||||
name: 'Test Tester',
|
||||
})
|
||||
})
|
||||
|
||||
it('sso human: emits email + issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg() })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
|
||||
})
|
||||
|
||||
it('sso json: emits {subject_type, email, issuer}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: ssoReg(), json: true })
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b, json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
subject_type: 'external_sso',
|
||||
email: 'sso@dify.ai',
|
||||
|
||||
@ -1,31 +1,46 @@
|
||||
import type { Registry } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
export type WhoamiOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly reg: Registry
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
|
||||
const active = opts.reg.requireActive()
|
||||
const b = opts.bundle
|
||||
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
|
||||
const sub = active.ctx.external_subject
|
||||
if (sub !== undefined) {
|
||||
if (b.external_subject !== undefined) {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
|
||||
opts.io.out.write(`${JSON.stringify({
|
||||
subject_type: 'external_sso',
|
||||
email: b.external_subject.email,
|
||||
issuer: b.external_subject.issuer,
|
||||
})}\n`)
|
||||
return
|
||||
}
|
||||
const sub = b.external_subject
|
||||
opts.io.out.write(sub.issuer !== ''
|
||||
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
|
||||
: `${sub.email} (external SSO)\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const acc = active.ctx.account
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
|
||||
opts.io.out.write(acc.name !== ''
|
||||
? `${acc.email} (${acc.name})\n`
|
||||
: `${acc.email}\n`)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runCreateMember } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'inviter@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +35,7 @@ describe('runCreateMember', () => {
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -92,7 +91,7 @@ describe('runCreateMember', () => {
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { MembersClient } from '@/api/members'
|
||||
import { BaseError } from '@/errors/base'
|
||||
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runCreateMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
|
||||
@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runDeleteMember } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runDeleteMember', () => {
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -46,7 +45,7 @@ describe('runDeleteMember', () => {
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -61,7 +60,7 @@ describe('runDeleteMember', () => {
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '@/api/members'
|
||||
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -51,7 +51,7 @@ export async function runDeleteMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
|
||||
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
format,
|
||||
data: await runDescribeApp(
|
||||
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -12,18 +12,17 @@ import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, getCache } from '@/store/manager'
|
||||
import { runDescribeApp } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,7 +49,7 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
|
||||
}
|
||||
@ -93,13 +92,13 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const before = cache.get(mock.url, 'app-1')
|
||||
expect(before).toBeDefined()
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1', refresh: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const after = cache.get(mock.url, 'app-1')
|
||||
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
|
||||
@ -113,7 +112,7 @@ describe('runDescribeApp', () => {
|
||||
await expect(runDescribeApp(
|
||||
{ appId: 'nope' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
|
||||
}
|
||||
|
||||
export type DescribeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io?: IOStreams
|
||||
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { TableCell, TableColumn } from '@/framework/output'
|
||||
import type { TableCell } from '@/framework/output'
|
||||
import type { TableColumn } from '@/printers/format-table'
|
||||
|
||||
export const APP_MODE_KEY = 'app'
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
format,
|
||||
data: result.data,
|
||||
|
||||
5
cli/src/commands/get/app/payload-shape.ts
Normal file
5
cli/src/commands/get/app/payload-shape.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function isPayloadShape<T>(value: unknown, requiredKey: keyof T): value is T {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& requiredKey in value
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { stringifyOutput, table } from '@/framework/output'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '@/http/client'
|
||||
import { AppListOutput } from './handlers'
|
||||
import { runGetApp } from './run'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetApp', () => {
|
||||
@ -37,7 +36,7 @@ describe('runGetApp', () => {
|
||||
}
|
||||
|
||||
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
|
||||
const result = await runGetApp(opts, { active: baseActive, http: http() })
|
||||
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
|
||||
return stringifyOutput(table({
|
||||
format: opts.format ?? '',
|
||||
data: result.data,
|
||||
@ -135,11 +134,7 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
|
||||
const minimal: ActiveContext = {
|
||||
host: 'h',
|
||||
email: 'x@x.com',
|
||||
ctx: { account: { email: 'x@x.com', name: 'X' } },
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
|
||||
await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
@ -24,7 +24,7 @@ export type GetAppOptions = {
|
||||
}
|
||||
|
||||
export type GetAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
return runAllWorkspaces(apps, ws, opts, page, pageSize)
|
||||
}
|
||||
if (opts.appId !== undefined && opts.appId !== '') {
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, wsId)
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsName = workspaceNameForId(deps.bundle, wsId)
|
||||
const desc = await apps.describe(opts.appId, wsId, ['info'])
|
||||
return describeToEnvelope(desc, wsId, wsName)
|
||||
}
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
@ -111,13 +111,12 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
function workspaceNameForId(b: HostsBundle, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (b.workspace?.id === id)
|
||||
return b.workspace.name
|
||||
for (const w of b.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { MemberListResponse, MemberResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { TableCell, TableColumn } from '@/framework/output'
|
||||
import type { TableCell } from '@/framework/output'
|
||||
import type { TableColumn } from '@/printers/format-table'
|
||||
|
||||
export const MEMBER_MODE_KEY = 'member'
|
||||
const CURRENT_MARKER = '*'
|
||||
|
||||
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,19 +1,18 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runGetMember } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,7 +37,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -55,7 +54,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -70,7 +69,7 @@ describe('runGetMember', () => {
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -79,20 +78,14 @@ describe('runGetMember', () => {
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when active context has no account id', async () => {
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const a: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: '', email: '', name: '' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
},
|
||||
}
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: a,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -103,16 +96,16 @@ describe('runGetMember', () => {
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
const noWs: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } },
|
||||
}
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
active: noWs,
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
@ -139,7 +132,7 @@ describe('MemberListOutput shape', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { MembersClient } from '@/api/members'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit'
|
||||
@ -16,7 +16,7 @@ export type GetMemberOptions = {
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -39,7 +39,7 @@ export async function runGetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -50,7 +50,7 @@ export async function runGetMember(
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.active.ctx.account?.id ?? ''
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { WorkspaceListOutput, WorkspaceRow } from './handlers'
|
||||
import { newWorkspaceObject, WORKSPACE_MODE_KEY, WorkspaceListOutput, WorkspaceRow } from './handlers'
|
||||
|
||||
function env(): WorkspaceListResponse {
|
||||
return {
|
||||
@ -12,6 +12,12 @@ function env(): WorkspaceListResponse {
|
||||
}
|
||||
|
||||
describe('get/workspace handlers', () => {
|
||||
it('newWorkspaceObject mode = workspace + raw passthrough', () => {
|
||||
const obj = newWorkspaceObject(env())
|
||||
expect(obj.mode()).toBe(WORKSPACE_MODE_KEY)
|
||||
expect(obj.raw().workspaces[0]?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('WorkspaceRow defines table, name, and json print shapes', () => {
|
||||
const row = new WorkspaceRow('ws-1', 'Default', 'owner', 'normal', true)
|
||||
expect(row.tableRow()).toEqual(['ws-1', 'Default', 'owner', 'normal', '*'])
|
||||
|
||||
@ -1,8 +1,23 @@
|
||||
import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { TableCell, TableColumn } from '@/framework/output'
|
||||
import type { TableCell } from '@/framework/output'
|
||||
import type { TableColumn, TableHandler, TableRow } from '@/printers/format-table'
|
||||
import { isPayloadShape } from '@/commands/get/app/payload-shape'
|
||||
|
||||
export const WORKSPACE_MODE_KEY = 'workspace'
|
||||
const CURRENT_MARKER = '*'
|
||||
|
||||
export type WorkspaceObject = {
|
||||
mode: () => string
|
||||
raw: () => WorkspaceListResponse
|
||||
}
|
||||
|
||||
export function newWorkspaceObject(env: WorkspaceListResponse): WorkspaceObject {
|
||||
return {
|
||||
mode: () => WORKSPACE_MODE_KEY,
|
||||
raw: () => env,
|
||||
}
|
||||
}
|
||||
|
||||
export const WORKSPACE_COLUMNS: readonly TableColumn[] = [
|
||||
{ name: 'ID', priority: 0 },
|
||||
{ name: 'NAME', priority: 0 },
|
||||
@ -86,3 +101,20 @@ export class WorkspaceListOutput {
|
||||
return this.envelope
|
||||
}
|
||||
}
|
||||
|
||||
export function workspaceTableHandler(currentId: string): TableHandler {
|
||||
return {
|
||||
columns: () => WORKSPACE_COLUMNS,
|
||||
rows: (raw): readonly TableRow[] => {
|
||||
if (!isPayloadShape<WorkspaceListResponse>(raw, 'workspaces'))
|
||||
throw new Error('get/workspace table: unexpected payload shape')
|
||||
return raw.workspaces.map(w => [
|
||||
w.id,
|
||||
w.name,
|
||||
w.role,
|
||||
w.status,
|
||||
w.current || (currentId !== '' && w.id === currentId) ? CURRENT_MARKER : '',
|
||||
])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand {
|
||||
const { flags } = this.parse(GetWorkspace, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
if (result.kind === 'empty')
|
||||
return raw(result.message)
|
||||
return table({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { stringifyOutput, table } from '@/framework/output'
|
||||
@ -7,18 +7,17 @@ import { createClient } from '@/http/client'
|
||||
import { WorkspaceListOutput } from './handlers'
|
||||
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run'
|
||||
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetWorkspace', () => {
|
||||
@ -36,8 +35,8 @@ describe('runGetWorkspace', () => {
|
||||
return createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
}
|
||||
|
||||
async function render(format = '', activeCtx = baseActive): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
|
||||
async function render(format = '', bundle = baseBundle): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { bundle, http: http() })
|
||||
if (result.kind === 'empty')
|
||||
return result.message
|
||||
return stringifyOutput(table({
|
||||
@ -76,8 +75,8 @@ describe('runGetWorkspace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to active context workspace.id when server current=false', async () => {
|
||||
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } }
|
||||
it('falls back to bundle workspace.id when server current=false', async () => {
|
||||
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
|
||||
const out = await render('', overridden)
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('ws-2'))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type GetWorkspaceDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
|
||||
)
|
||||
if (env.workspaces.length === 0)
|
||||
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
|
||||
const currentId = deps.active.ctx.workspace?.id ?? ''
|
||||
const currentId = deps.bundle.workspace?.id ?? ''
|
||||
return {
|
||||
kind: 'output',
|
||||
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(
|
||||
|
||||
@ -14,10 +14,7 @@ export const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
|
||||
|
||||
Tips:
|
||||
* Pass --workspace <id> when you need to target a non-default workspace.
|
||||
* Use --stream for long-running workflow calls.
|
||||
* 'difyctl auth list' shows all authenticated hosts and accounts.
|
||||
* 'difyctl use host [--domain <host>]' switches the active Dify instance.
|
||||
* 'difyctl use account [--email <email>]' switches accounts on the current host.
|
||||
* Use --stream for long-running workflow calls (post-v1.0 milestone).
|
||||
* 'difyctl env list' shows every env var difyctl reads.
|
||||
`
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { RunContext } from '@/commands/run/app/_strategies/index'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
@ -8,6 +8,7 @@ import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { pickStrategy } from '@/commands/run/app/_strategies/index'
|
||||
import { RUN_MODES } from '@/commands/run/app/handlers'
|
||||
import { AppRunPrintFlags } from '@/commands/run/app/print-flags'
|
||||
import { getEnv, processExit } from '@/sys/index'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { FieldInfo } from '@/types/app-meta'
|
||||
@ -29,7 +30,7 @@ export type ResumeAppOptions = {
|
||||
}
|
||||
|
||||
export type ResumeAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -77,7 +78,7 @@ async function resolveInputs(
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
@ -115,6 +116,7 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr
|
||||
deps.io.err.write(` ${cs.dim('workflow execution resumed')}\n`)
|
||||
}
|
||||
const livePrint = opts.stream === true
|
||||
const printFlags = new AppRunPrintFlags()
|
||||
|
||||
const adaptedRunClient = {
|
||||
runStream: (_appId: string, _body: unknown, streamOpts?: { signal?: AbortSignal }) =>
|
||||
@ -144,6 +146,7 @@ export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Pr
|
||||
isText,
|
||||
livePrint,
|
||||
runClient: adaptedRunClient as unknown as AppRunClient,
|
||||
printFlags,
|
||||
exit,
|
||||
think: opts.think ?? false,
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { AppRunClient } from '@/api/app-run'
|
||||
import type { AppRunPrintFlags } from '@/commands/run/app/print-flags'
|
||||
import type { RunAppDeps, RunAppOptions } from '@/commands/run/app/run'
|
||||
import { StreamingStructuredStrategy } from './streaming-structured'
|
||||
import { StreamingTextStrategy } from './streaming-text'
|
||||
@ -11,6 +12,7 @@ export type RunContext = {
|
||||
readonly isText: boolean
|
||||
readonly livePrint: boolean
|
||||
readonly runClient: AppRunClient
|
||||
readonly printFlags: AppRunPrintFlags
|
||||
readonly exit: (code: number) => never
|
||||
readonly think: boolean
|
||||
}
|
||||
|
||||
@ -4,8 +4,6 @@ import { buildRunBody } from '@/api/app-run'
|
||||
import { chatConversationHint, newAppRunObject, RUN_MODES } from '@/commands/run/app/handlers'
|
||||
import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render'
|
||||
import { collect, HitlPauseError } from '@/commands/run/app/sse-collector'
|
||||
import { formatted, stringifyOutput } from '@/framework/output'
|
||||
import { handle, unhandle } from '@/sys/index'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { startSpinner } from '@/sys/io/spinner'
|
||||
import { extractThinkBlocks, stripThinkBlocks } from '@/sys/io/think-filter'
|
||||
@ -32,7 +30,7 @@ async function* captureTaskId(
|
||||
|
||||
export class StreamingStructuredStrategy implements RunStrategy {
|
||||
async execute(ctx: RunContext): Promise<void> {
|
||||
const { opts, deps, mode, format, isText, exit } = ctx
|
||||
const { opts, deps, mode, format, isText, printFlags, exit } = ctx
|
||||
const ctrl = new AbortController()
|
||||
const body = buildRunBody({
|
||||
message: opts.message,
|
||||
@ -52,7 +50,7 @@ export class StreamingStructuredStrategy implements RunStrategy {
|
||||
ctrl.abort()
|
||||
exit(1)
|
||||
}
|
||||
handle('SIGINT', cleanup)
|
||||
process.once('SIGINT', cleanup)
|
||||
|
||||
let resp: Record<string, unknown>
|
||||
try {
|
||||
@ -74,7 +72,7 @@ export class StreamingStructuredStrategy implements RunStrategy {
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
unhandle('SIGINT', cleanup)
|
||||
process.off('SIGINT', cleanup)
|
||||
}
|
||||
let processedResp = resp
|
||||
if (typeof processedResp.answer === 'string') {
|
||||
@ -90,7 +88,7 @@ export class StreamingStructuredStrategy implements RunStrategy {
|
||||
}
|
||||
|
||||
const respMode = typeof processedResp.mode === 'string' && processedResp.mode !== '' ? processedResp.mode : mode
|
||||
deps.io.out.write(stringifyOutput(formatted({ format, data: newAppRunObject(respMode, processedResp) })))
|
||||
deps.io.out.write(printFlags.toPrinter(format).print(newAppRunObject(respMode, processedResp)))
|
||||
if (isText && CHAT_MODES.has(respMode)) {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const hint = chatConversationHint(processedResp, cs)
|
||||
|
||||
@ -2,12 +2,11 @@ import type { RunContext, RunStrategy } from './index'
|
||||
import { buildRunBody } from '@/api/app-run'
|
||||
import { renderHitlHint, renderHitlOutput } from '@/commands/run/app/hitl-render'
|
||||
import { decodeStreamError, HitlPauseError } from '@/commands/run/app/sse-collector'
|
||||
import { streamPrinterFor } from '@/commands/run/app/stream-handlers'
|
||||
import { handle, unhandle } from '@/sys/index'
|
||||
|
||||
export class StreamingTextStrategy implements RunStrategy {
|
||||
async execute(ctx: RunContext): Promise<void> {
|
||||
const { opts, deps, mode, exit } = ctx
|
||||
const { opts, deps, mode, printFlags, exit } = ctx
|
||||
const ctrl = new AbortController()
|
||||
const body = buildRunBody({
|
||||
message: opts.message,
|
||||
@ -29,7 +28,7 @@ export class StreamingTextStrategy implements RunStrategy {
|
||||
|
||||
try {
|
||||
const events = await ctx.runClient.runStream(opts.appId, body, { signal: ctrl.signal })
|
||||
const sp = streamPrinterFor(mode, ctx.think, deps.io.isErrTTY)
|
||||
const sp = printFlags.toStreamPrinter(mode, ctx.think, deps.io.isErrTTY)
|
||||
const dec = new TextDecoder()
|
||||
for await (const ev of events) {
|
||||
if (ev.name === 'ping')
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { FormattedPrintable } from '@/framework/output'
|
||||
import type { TextHandler } from '@/printers/format-text'
|
||||
import type { ColorScheme } from '@/sys/io/color'
|
||||
|
||||
export const RUN_MODES = {
|
||||
@ -11,58 +11,53 @@ export const RUN_MODES = {
|
||||
|
||||
export type RunMode = typeof RUN_MODES[keyof typeof RUN_MODES]
|
||||
|
||||
export type AppRunObject = FormattedPrintable
|
||||
export type AppRunObject = {
|
||||
mode: () => string
|
||||
raw: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
export function newAppRunObject(mode: string, resp: Record<string, unknown>): AppRunObject {
|
||||
const filled = resp.mode === undefined || resp.mode === '' ? { ...resp, mode } : resp
|
||||
return {
|
||||
text: () => textForMode(mode, filled),
|
||||
json: () => filled,
|
||||
}
|
||||
return { mode: () => mode, raw: () => filled }
|
||||
}
|
||||
|
||||
function textForMode(mode: string, raw: Record<string, unknown>): string {
|
||||
switch (mode) {
|
||||
case RUN_MODES.Chat:
|
||||
case RUN_MODES.AgentChat:
|
||||
case RUN_MODES.AdvancedChat:
|
||||
return renderChat(raw)
|
||||
case RUN_MODES.Completion:
|
||||
return renderCompletion(raw)
|
||||
case RUN_MODES.Workflow:
|
||||
return renderWorkflow(raw)
|
||||
default:
|
||||
return `${JSON.stringify(raw)}\n`
|
||||
}
|
||||
export const chatTextHandler: TextHandler = {
|
||||
render(raw): string {
|
||||
const resp = raw as Record<string, unknown>
|
||||
const out: string[] = []
|
||||
const answer = pickString(resp, 'answer')
|
||||
if (answer !== undefined)
|
||||
out.push(answer)
|
||||
out.push('')
|
||||
return out.join('\n')
|
||||
},
|
||||
}
|
||||
|
||||
function renderChat(raw: Record<string, unknown>): string {
|
||||
const out: string[] = []
|
||||
const answer = pickString(raw, 'answer')
|
||||
if (answer !== undefined)
|
||||
out.push(answer)
|
||||
out.push('')
|
||||
return out.join('\n')
|
||||
export const completionTextHandler: TextHandler = {
|
||||
render(raw): string {
|
||||
const resp = raw as Record<string, unknown>
|
||||
const answer = pickString(resp, 'answer')
|
||||
return `${answer ?? ''}\n`
|
||||
},
|
||||
}
|
||||
|
||||
function renderCompletion(raw: Record<string, unknown>): string {
|
||||
return `${pickString(raw, 'answer') ?? ''}\n`
|
||||
}
|
||||
|
||||
function renderWorkflow(raw: Record<string, unknown>): string {
|
||||
const data = raw.data
|
||||
if (data !== null && typeof data === 'object' && 'outputs' in data) {
|
||||
const { outputs } = data as { outputs: unknown }
|
||||
if (outputs !== undefined) {
|
||||
if (typeof outputs === 'object' && outputs !== null) {
|
||||
const entries = Object.entries(outputs as Record<string, unknown>)
|
||||
if (entries.length === 1 && typeof entries[0]![1] === 'string')
|
||||
return `${entries[0]![1]}\n`
|
||||
export const workflowTextHandler: TextHandler = {
|
||||
render(raw): string {
|
||||
const resp = raw as Record<string, unknown>
|
||||
const data = resp.data
|
||||
if (data !== null && typeof data === 'object' && 'outputs' in data) {
|
||||
const { outputs } = data as { outputs: unknown }
|
||||
if (outputs !== undefined) {
|
||||
if (typeof outputs === 'object' && outputs !== null) {
|
||||
const entries = Object.entries(outputs as Record<string, unknown>)
|
||||
if (entries.length === 1 && typeof entries[0]![1] === 'string')
|
||||
return `${entries[0]![1]}\n`
|
||||
}
|
||||
return `${JSON.stringify(outputs)}\n`
|
||||
}
|
||||
return `${JSON.stringify(outputs)}\n`
|
||||
}
|
||||
}
|
||||
return `${JSON.stringify(raw)}\n`
|
||||
return `${JSON.stringify(resp)}\n`
|
||||
},
|
||||
}
|
||||
|
||||
export function chatConversationHint(resp: Record<string, unknown>, cs: ColorScheme): string | undefined {
|
||||
|
||||
@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
27
cli/src/commands/run/app/print-flags.ts
Normal file
27
cli/src/commands/run/app/print-flags.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { PrintFlags } from '@/printers/printer'
|
||||
import type { StreamPrinter } from '@/printers/stream-printer'
|
||||
import { JsonYamlPrintFlags } from '@/printers/format-json-yaml'
|
||||
import { TextPrintFlags } from '@/printers/format-text'
|
||||
import { CompositePrintFlags } from '@/printers/printer'
|
||||
import { chatTextHandler, completionTextHandler, RUN_MODES, workflowTextHandler } from './handlers'
|
||||
import { streamPrinterFor } from './stream-handlers'
|
||||
|
||||
export class AppRunPrintFlags extends CompositePrintFlags {
|
||||
private readonly jsonYaml = new JsonYamlPrintFlags()
|
||||
private readonly text = new TextPrintFlags()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.text.register(chatTextHandler, RUN_MODES.Chat, RUN_MODES.AgentChat, RUN_MODES.AdvancedChat)
|
||||
this.text.register(completionTextHandler, RUN_MODES.Completion)
|
||||
this.text.register(workflowTextHandler, RUN_MODES.Workflow)
|
||||
}
|
||||
|
||||
protected families(): readonly PrintFlags[] {
|
||||
return [this.jsonYaml, this.text]
|
||||
}
|
||||
|
||||
toStreamPrinter(mode: string, think = false, isTTY = false): StreamPrinter {
|
||||
return streamPrinterFor(mode, think, isTTY)
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -13,18 +13,17 @@ import { CACHE_APP_INFO, getCache } from '@/store/manager'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runApp } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +51,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: hi\n')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -63,7 +62,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
|
||||
})
|
||||
|
||||
@ -72,7 +71,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -82,7 +81,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -93,7 +92,7 @@ describe('runApp', () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', format: 'bogus' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
@ -102,7 +101,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'nope', message: 'hi' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -115,7 +114,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('echo: ')
|
||||
expect(io.outBuf()).toContain('hi')
|
||||
@ -127,7 +126,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -140,7 +139,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('do research')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -151,7 +150,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('go')
|
||||
expect(io.errBuf()).toContain('thought:')
|
||||
@ -162,7 +161,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
|
||||
expect(parsed.mode).toBe('workflow')
|
||||
@ -175,7 +174,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
})
|
||||
|
||||
@ -187,7 +186,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -199,7 +198,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/must be a JSON object/)
|
||||
})
|
||||
|
||||
@ -208,7 +207,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -220,7 +219,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, '{}')
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsJson: '{}', inputsFile },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
|
||||
@ -232,7 +231,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -261,7 +260,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -285,7 +284,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -296,7 +295,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -307,7 +306,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
// stream mode for workflow: node_started → "→ <title>" on stderr
|
||||
expect(io.errBuf()).toContain('After Resume')
|
||||
@ -318,7 +317,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(0)
|
||||
@ -339,7 +338,7 @@ describe('runApp', () => {
|
||||
await writeFile(filePath, 'fake pdf content')
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: [`doc=@${filePath}`] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(1)
|
||||
@ -356,7 +355,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
@ -14,6 +14,7 @@ import { FieldInfo } from '@/types/app-meta'
|
||||
import { resolveWorkspaceId } from '@/workspace/resolver'
|
||||
import { resolveFileInputs } from './file-flags'
|
||||
import { RUN_MODES } from './handlers'
|
||||
import { AppRunPrintFlags } from './print-flags'
|
||||
|
||||
export type RunAppOptions = {
|
||||
readonly appId: string
|
||||
@ -31,7 +32,7 @@ export type RunAppOptions = {
|
||||
}
|
||||
|
||||
export type RunAppDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -79,7 +80,7 @@ async function resolveInputs(
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, wsId, [FieldInfo])
|
||||
@ -109,8 +110,9 @@ export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<voi
|
||||
const isText = TEXT_FORMATS.has(format)
|
||||
const livePrint = opts.stream === true
|
||||
const runClient = new AppRunClient(deps.http)
|
||||
const printFlags = new AppRunPrintFlags()
|
||||
|
||||
const exit = deps.exit ?? processExit
|
||||
const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, exit, think: opts.think ?? false }
|
||||
const ctx = { opts: { ...opts, inputs }, deps, mode, format, isText, livePrint, runClient, printFlags, exit, think: opts.think ?? false }
|
||||
await pickStrategy(isText, livePrint).execute(ctx)
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { HitlPausePayload } from './sse-collector'
|
||||
import type { StreamPrinter } from '@/framework/stream'
|
||||
import type { SseEvent } from '@/http/sse'
|
||||
import type { StreamPrinter } from '@/printers/stream-printer'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
|
||||
@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runSetMember } from './run'
|
||||
|
||||
function active(): ActiveContext {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,7 +27,7 @@ describe('runSetMember', () => {
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -47,7 +46,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -63,7 +62,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -77,7 +76,7 @@ describe('runSetMember', () => {
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
active: active(),
|
||||
bundle: bundle(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { MembersClient } from '@/api/members'
|
||||
import { BaseError } from '@/errors/base'
|
||||
@ -18,7 +18,7 @@ export type SetMemberOptions = {
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runSetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
active: deps.active,
|
||||
bundle: deps.bundle,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
import type { CommandTree } from '@/framework/registry'
|
||||
import AuthDevicesList from '@/commands/auth/devices/list/index'
|
||||
import AuthDevicesRevoke from '@/commands/auth/devices/revoke/index'
|
||||
import AuthList from '@/commands/auth/list/index'
|
||||
import AuthLogin from '@/commands/auth/login/index'
|
||||
import AuthLogout from '@/commands/auth/logout/index'
|
||||
import AuthStatus from '@/commands/auth/status/index'
|
||||
import AuthWhoami from '@/commands/auth/whoami/index'
|
||||
import ConfigGet from '@/commands/config/get/index'
|
||||
import ConfigPath from '@/commands/config/path/index'
|
||||
@ -26,8 +26,6 @@ import HelpExternal from '@/commands/help/external/index'
|
||||
import ResumeApp from '@/commands/resume/app/index'
|
||||
import RunApp from '@/commands/run/app/index'
|
||||
import SetMember from '@/commands/set/member/index'
|
||||
import UseAccount from '@/commands/use/account/index'
|
||||
import UseHost from '@/commands/use/host/index'
|
||||
import UseWorkspace from '@/commands/use/workspace/index'
|
||||
import Version from '@/commands/version/index'
|
||||
|
||||
@ -40,9 +38,9 @@ export const commandTree: CommandTree = {
|
||||
revoke: { command: AuthDevicesRevoke, subcommands: {} },
|
||||
},
|
||||
},
|
||||
list: { command: AuthList, subcommands: {} },
|
||||
login: { command: AuthLogin, subcommands: {} },
|
||||
logout: { command: AuthLogout, subcommands: {} },
|
||||
status: { command: AuthStatus, subcommands: {} },
|
||||
whoami: { command: AuthWhoami, subcommands: {} },
|
||||
},
|
||||
},
|
||||
@ -106,8 +104,6 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
account: { command: UseAccount, subcommands: {} },
|
||||
host: { command: UseHost, subcommands: {} },
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { runUseAccount } from './use-account'
|
||||
|
||||
export default class UseAccount extends DifyCommand {
|
||||
static override description = 'Switch the active account on the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use account',
|
||||
'<%= config.bin %> use account --email bob@corp.com',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
email: Flags.string({ description: 'email of the account to switch to (interactive picker shown when omitted in TTY)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseAccount, argv)
|
||||
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
import type { Key, Store } from '@/store/store'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runUseAccount } from './use-account'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseAccount', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
it('errors when the account has no stored token', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
|
||||
.rejects
|
||||
.toThrow(/log in|no credential/i)
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when email omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
|
||||
})
|
||||
})
|
||||
@ -1,76 +0,0 @@
|
||||
import type { HostEntry } from '@/auth/hosts'
|
||||
import type { Store } from '@/store/store'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getTokenStore, tokenKey } from '@/store/manager'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { selectFromList } from '@/sys/io/select'
|
||||
|
||||
export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
|
||||
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
|
||||
|
||||
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
if (reg.current_host === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
const host = reg.current_host
|
||||
const entry = reg.hosts[host]
|
||||
if (entry === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
|
||||
const emails = Object.keys(entry.accounts)
|
||||
const target = opts.email ?? await pickAccount(opts, entry, host)
|
||||
if (!emails.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
hint: `run 'difyctl auth login --host ${host}'`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setAccount(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
|
||||
const emails = Object.keys(entry.accounts)
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
|
||||
email,
|
||||
name: ctx.account.name,
|
||||
sso: ctx.external_subject !== undefined,
|
||||
active: entry.current_account === email,
|
||||
}))
|
||||
const picked = await selectFromList<AccountChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: `Select an account on ${host}`,
|
||||
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
|
||||
})
|
||||
return picked.email
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { realStreams } from '@/sys/io/streams'
|
||||
import { runUseHost } from './use-host'
|
||||
|
||||
export default class UseHost extends DifyCommand {
|
||||
static override description = 'Switch the active Dify host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use host',
|
||||
'<%= config.bin %> use host --domain cloud.dify.ai',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
domain: Flags.string({ description: 'host domain to switch to, e.g. cloud.dify.ai (interactive picker shown when omitted in TTY)', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseHost, argv)
|
||||
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runUseHost } from './use-host'
|
||||
|
||||
describe('runUseHost', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_host when host is valid', async () => {
|
||||
await runUseHost({ io: bufferStreams(), host: 'h2' })
|
||||
expect(Registry.load().current_host).toBe('h2')
|
||||
})
|
||||
|
||||
it('errors when host is unknown, listing valid hosts', async () => {
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when host omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
|
||||
})
|
||||
|
||||
it('errors when no hosts exist', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
|
||||
})
|
||||
})
|
||||
@ -1,54 +0,0 @@
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { selectFromList } from '@/sys/io/select'
|
||||
|
||||
export type UseHostOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly host: string | undefined
|
||||
}
|
||||
|
||||
type HostChoice = { host: string, accounts: number, active: boolean }
|
||||
|
||||
export async function runUseHost(opts: UseHostOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
const hosts = Object.keys(reg.hosts)
|
||||
if (hosts.length === 0)
|
||||
throw notLoggedInError()
|
||||
|
||||
const target = opts.host ?? await pickHost(opts, reg, hosts)
|
||||
if (!hosts.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setHost(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: HostChoice[] = hosts.map(h => ({
|
||||
host: h,
|
||||
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
|
||||
active: reg.current_host === h,
|
||||
}))
|
||||
const picked = await selectFromList<HostChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: 'Select a host',
|
||||
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
|
||||
})
|
||||
return picked.host
|
||||
}
|
||||
@ -22,8 +22,7 @@ export default class UseWorkspace extends DifyCommand {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
bundle: ctx.bundle,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
|
||||
@ -3,36 +3,28 @@ import type {
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { loadHosts, saveHosts } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { runUseWorkspace } from './use'
|
||||
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
return reg
|
||||
}
|
||||
|
||||
function makeActive(reg: Registry): ActiveContext {
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
throw new Error('resolveActive returned undefined in test setup')
|
||||
return active
|
||||
}
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
@ -76,16 +68,14 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -94,65 +84,40 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -162,8 +127,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -172,18 +136,16 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = Registry.load()
|
||||
const after = loadHosts()
|
||||
expect(after).toEqual(before)
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -193,8 +155,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -202,15 +163,14 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = Registry.load()
|
||||
const after = loadHosts()
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -232,8 +192,7 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
reg,
|
||||
active,
|
||||
bundle: b,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { ActiveContext, Registry, Workspace } from '@/auth/hosts'
|
||||
import type { HostsBundle, Workspace } from '@/auth/hosts'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { saveHosts } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
@ -12,8 +13,7 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly bundle: HostsBundle
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -31,12 +31,12 @@ export type UseWorkspaceDeps = {
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<Registry> {
|
||||
): Promise<HostsBundle> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
@ -60,13 +60,16 @@ export async function runUseWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
}
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
saveHosts(next)
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return deps.reg
|
||||
return next
|
||||
}
|
||||
|
||||
59
cli/src/printers/format-json-yaml.test.ts
Normal file
59
cli/src/printers/format-json-yaml.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { JsonYamlPrintFlags } from './format-json-yaml'
|
||||
import { isNoCompatiblePrinter } from './printer'
|
||||
|
||||
describe('JsonYamlPrintFlags.allowedFormats', () => {
|
||||
it('returns json + yaml', () => {
|
||||
expect(new JsonYamlPrintFlags().allowedFormats()).toEqual(['json', 'yaml'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('JsonYamlPrintFlags.toPrinter', () => {
|
||||
it('throws NoCompatiblePrinterError for unsupported formats', () => {
|
||||
const pf = new JsonYamlPrintFlags()
|
||||
for (const f of ['', 'text', 'wide', 'name', 'xml']) {
|
||||
let caught: unknown
|
||||
try {
|
||||
pf.toPrinter(f)
|
||||
}
|
||||
catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isNoCompatiblePrinter(caught)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a json printer that encodes raw payload with 2-space indent', () => {
|
||||
const p = new JsonYamlPrintFlags().toPrinter('json')
|
||||
const out = p.print({ raw: () => ({ answer: 'hi' }) })
|
||||
expect(out).toContain('"answer"')
|
||||
expect(out).toContain('"hi"')
|
||||
expect(out).toContain(' "answer"')
|
||||
expect(out.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('json printer round-trips a plain object with no Raw()', () => {
|
||||
const p = new JsonYamlPrintFlags().toPrinter('json')
|
||||
const out = p.print({ k: 'v', n: 1 })
|
||||
expect(JSON.parse(out)).toEqual({ k: 'v', n: 1 })
|
||||
})
|
||||
|
||||
it('json printer is lossless for nested arrays', () => {
|
||||
const data = { items: [{ id: 'a' }, { id: 'b' }] }
|
||||
const out = new JsonYamlPrintFlags().toPrinter('json').print(data)
|
||||
expect(JSON.parse(out)).toEqual(data)
|
||||
})
|
||||
|
||||
it('returns a yaml printer that emits scalar pairs', () => {
|
||||
const p = new JsonYamlPrintFlags().toPrinter('yaml')
|
||||
const out = p.print({ raw: () => ({ answer: 'hi' }) })
|
||||
expect(out).toMatch(/answer:\s*['"]?hi['"]?\n?/)
|
||||
})
|
||||
|
||||
it('yaml printer round-trips structured data', async () => {
|
||||
const yaml = await import('js-yaml')
|
||||
const data = { items: [{ id: 'a', mode: 'chat' }, { id: 'b', mode: 'workflow' }] }
|
||||
const out = new JsonYamlPrintFlags().toPrinter('yaml').print(data)
|
||||
expect(yaml.load(out)).toEqual(data)
|
||||
})
|
||||
})
|
||||
31
cli/src/printers/format-json-yaml.ts
Normal file
31
cli/src/printers/format-json-yaml.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import type { Printer, PrintFlags } from './printer'
|
||||
import yaml from 'js-yaml'
|
||||
import { NoCompatiblePrinterError, payload } from './printer'
|
||||
|
||||
const ALLOWED = ['json', 'yaml'] as const
|
||||
|
||||
const jsonPrinter: Printer = {
|
||||
print(obj) {
|
||||
return `${JSON.stringify(payload(obj), null, 2)}\n`
|
||||
},
|
||||
}
|
||||
|
||||
const yamlPrinter: Printer = {
|
||||
print(obj) {
|
||||
return yaml.dump(payload(obj), { indent: 2, lineWidth: -1 })
|
||||
},
|
||||
}
|
||||
|
||||
export class JsonYamlPrintFlags implements PrintFlags {
|
||||
allowedFormats(): readonly string[] {
|
||||
return ALLOWED
|
||||
}
|
||||
|
||||
toPrinter(format: string): Printer {
|
||||
switch (format) {
|
||||
case 'json': return jsonPrinter
|
||||
case 'yaml': return yamlPrinter
|
||||
default: throw new NoCompatiblePrinterError(format, ALLOWED)
|
||||
}
|
||||
}
|
||||
}
|
||||
76
cli/src/printers/format-name.test.ts
Normal file
76
cli/src/printers/format-name.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { NamePrintFlags } from './format-name'
|
||||
import { isNoCompatiblePrinter } from './printer'
|
||||
|
||||
const fakeMode = (m: string) => ({ mode: () => m })
|
||||
|
||||
describe('NamePrintFlags.allowedFormats', () => {
|
||||
it('returns ["name"]', () => {
|
||||
expect(new NamePrintFlags().allowedFormats()).toEqual(['name'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('NamePrintFlags.toPrinter', () => {
|
||||
it('throws NoCompatiblePrinterError for non-name formats', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
let caught: unknown
|
||||
try {
|
||||
pf.toPrinter('json')
|
||||
}
|
||||
catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isNoCompatiblePrinter(caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('prints id + newline for the registered mode', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
pf.register({ id: () => 'abc-123' }, 'thing')
|
||||
expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc-123\n')
|
||||
})
|
||||
|
||||
it('appends operation suffix when set', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
pf.operation = 'created'
|
||||
pf.register({ id: () => 'abc' }, 'thing')
|
||||
expect(pf.toPrinter('name').print(fakeMode('thing'))).toBe('abc created\n')
|
||||
})
|
||||
|
||||
it('throws when payload mode has no registered handler', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
pf.register({ id: () => 'abc' }, 'thing')
|
||||
const printer = pf.toPrinter('name')
|
||||
expect(() => printer.print(fakeMode('other'))).toThrow(/no handler for mode/)
|
||||
})
|
||||
|
||||
it('throws when payload does not implement Moder', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
pf.register({ id: () => 'abc' }, 'thing')
|
||||
const printer = pf.toPrinter('name')
|
||||
expect(() => printer.print({ no: 'mode' })).toThrow(/does not implement Moder/i)
|
||||
})
|
||||
|
||||
it('register accepts multiple keys for the same handler', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
pf.register({ id: () => 'shared' }, 'a', 'b')
|
||||
const printer = pf.toPrinter('name')
|
||||
expect(printer.print(fakeMode('a'))).toBe('shared\n')
|
||||
expect(printer.print(fakeMode('b'))).toBe('shared\n')
|
||||
})
|
||||
|
||||
it('unwraps RawObject before passing payload to handler', () => {
|
||||
const pf = new NamePrintFlags()
|
||||
let received: unknown
|
||||
pf.register({
|
||||
id: (p) => {
|
||||
received = p
|
||||
return 'ok'
|
||||
},
|
||||
}, 'thing')
|
||||
pf.toPrinter('name').print({
|
||||
mode: () => 'thing',
|
||||
raw: () => ({ id: 'unwrapped' }),
|
||||
})
|
||||
expect(received).toEqual({ id: 'unwrapped' })
|
||||
})
|
||||
})
|
||||
42
cli/src/printers/format-name.ts
Normal file
42
cli/src/printers/format-name.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { Printer, PrintFlags } from './printer'
|
||||
import { isModer, NoCompatiblePrinterError, payload } from './printer'
|
||||
|
||||
const ALLOWED = ['name'] as const
|
||||
|
||||
export type NameHandler = {
|
||||
id: (raw: unknown) => string
|
||||
}
|
||||
|
||||
export class NamePrintFlags implements PrintFlags {
|
||||
operation = ''
|
||||
private readonly handlers = new Map<string, NameHandler>()
|
||||
|
||||
register(handler: NameHandler, ...keys: string[]): void {
|
||||
for (const k of keys) this.handlers.set(k, handler)
|
||||
}
|
||||
|
||||
allowedFormats(): readonly string[] {
|
||||
return ALLOWED
|
||||
}
|
||||
|
||||
toPrinter(format: string): Printer {
|
||||
if (format !== 'name')
|
||||
throw new NoCompatiblePrinterError(format, ALLOWED)
|
||||
const handlers = this.handlers
|
||||
const operation = this.operation
|
||||
return {
|
||||
print(obj) {
|
||||
if (!isModer(obj))
|
||||
throw new Error(`name printer: payload does not implement Moder`)
|
||||
const mode = obj.mode()
|
||||
const h = handlers.get(mode)
|
||||
if (h === undefined) {
|
||||
const known = [...handlers.keys()].sort().join(', ')
|
||||
throw new Error(`name printer: no handler for mode "${mode}" (registered: ${known})`)
|
||||
}
|
||||
const id = h.id(payload(obj))
|
||||
return operation === '' ? `${id}\n` : `${id} ${operation}\n`
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
136
cli/src/printers/format-table.test.ts
Normal file
136
cli/src/printers/format-table.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import type { TableColumn, TableHandler } from './format-table'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TablePrintFlags } from './format-table'
|
||||
import { isNoCompatiblePrinter } from './printer'
|
||||
|
||||
const fakeMode = (m: string) => ({ mode: () => m })
|
||||
|
||||
const handler: TableHandler = {
|
||||
columns(): readonly TableColumn[] {
|
||||
return [
|
||||
{ name: 'NAME', priority: 0 },
|
||||
{ name: 'AGE', priority: 0 },
|
||||
{ name: 'DETAILS', priority: 1 },
|
||||
]
|
||||
},
|
||||
rows() {
|
||||
return [['alpha', '1d', 'extra']]
|
||||
},
|
||||
}
|
||||
|
||||
describe('TablePrintFlags.allowedFormats', () => {
|
||||
it('returns ["", "wide"]', () => {
|
||||
expect(new TablePrintFlags().allowedFormats()).toEqual(['', 'wide'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TablePrintFlags default format', () => {
|
||||
it('hides priority>0 columns and their cells', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register(handler, 'thing')
|
||||
const out = pf.toPrinter('').print(fakeMode('thing'))
|
||||
expect(out).toContain('NAME')
|
||||
expect(out).toContain('AGE')
|
||||
expect(out).not.toContain('DETAILS')
|
||||
expect(out).not.toContain('extra')
|
||||
expect(out).toContain('alpha')
|
||||
})
|
||||
|
||||
it('column-aligns cells with two-space padding', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register({
|
||||
columns: () => [
|
||||
{ name: 'NAME', priority: 0 },
|
||||
{ name: 'AGE', priority: 0 },
|
||||
],
|
||||
rows: () => [
|
||||
['alpha', '1d'],
|
||||
['beta-long', '999d'],
|
||||
],
|
||||
}, 'thing')
|
||||
const out = pf.toPrinter('').print(fakeMode('thing'))
|
||||
const lines = out.trimEnd().split('\n')
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0]).toBe('NAME AGE')
|
||||
expect(lines[1]).toBe('alpha 1d')
|
||||
expect(lines[2]).toBe('beta-long 999d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TablePrintFlags wide format', () => {
|
||||
it('shows all columns including priority>0', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register(handler, 'thing')
|
||||
const out = pf.toPrinter('wide').print(fakeMode('thing'))
|
||||
expect(out).toContain('DETAILS')
|
||||
expect(out).toContain('extra')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TablePrintFlags noHeaders', () => {
|
||||
it('omits header row when noHeaders=true', () => {
|
||||
const pf = new TablePrintFlags({ noHeaders: true })
|
||||
pf.register(handler, 'thing')
|
||||
const out = pf.toPrinter('').print(fakeMode('thing'))
|
||||
expect(out).not.toContain('NAME')
|
||||
expect(out).toContain('alpha')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TablePrintFlags errors', () => {
|
||||
it('throws NoCompatiblePrinterError for unsupported formats', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
new TablePrintFlags().toPrinter('json')
|
||||
}
|
||||
catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isNoCompatiblePrinter(caught)).toBe(true)
|
||||
})
|
||||
|
||||
it('throws on unregistered mode', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register(handler, 'thing')
|
||||
const printer = pf.toPrinter('')
|
||||
expect(() => printer.print(fakeMode('other'))).toThrow(/other/)
|
||||
})
|
||||
|
||||
it('throws when payload does not implement Moder', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register(handler, 'thing')
|
||||
expect(() => pf.toPrinter('').print({})).toThrow(/Moder/i)
|
||||
})
|
||||
|
||||
it('handler rows() can return null/undefined cells safely (rendered empty)', () => {
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register({
|
||||
columns: () => [{ name: 'A', priority: 0 }, { name: 'B', priority: 0 }],
|
||||
rows: () => [['x', undefined], [null, 'y']],
|
||||
}, 'thing')
|
||||
const out = pf.toPrinter('').print(fakeMode('thing'))
|
||||
const lines = out.trimEnd().split('\n')
|
||||
expect(lines[0]).toBe('A B')
|
||||
expect(lines[1]).toBe('x ')
|
||||
expect(lines[2]).toBe(' y')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TablePrintFlags raw unwrap', () => {
|
||||
it('passes unwrapped payload to handler.rows()', () => {
|
||||
let received: unknown
|
||||
const pf = new TablePrintFlags()
|
||||
pf.register({
|
||||
columns: () => [{ name: 'X', priority: 0 }],
|
||||
rows: (p) => {
|
||||
received = p
|
||||
return [['ok']]
|
||||
},
|
||||
}, 'thing')
|
||||
pf.toPrinter('').print({
|
||||
mode: () => 'thing',
|
||||
raw: () => ({ items: [{ id: 'x' }] }),
|
||||
})
|
||||
expect(received).toEqual({ items: [{ id: 'x' }] })
|
||||
})
|
||||
})
|
||||
108
cli/src/printers/format-table.ts
Normal file
108
cli/src/printers/format-table.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { Printer, PrintFlags } from './printer'
|
||||
import { isModer, NoCompatiblePrinterError, payload } from './printer'
|
||||
|
||||
const ALLOWED = ['', 'wide'] as const
|
||||
const COLUMN_PADDING = 2
|
||||
|
||||
export type TableColumn = {
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
export type TableCell = string | null | undefined
|
||||
|
||||
export type TableRow = readonly TableCell[]
|
||||
|
||||
export type TableHandler = {
|
||||
columns: () => readonly TableColumn[]
|
||||
rows: (raw: unknown) => readonly TableRow[]
|
||||
}
|
||||
|
||||
export type TablePrintFlagsOptions = {
|
||||
noHeaders?: boolean
|
||||
}
|
||||
|
||||
export class TablePrintFlags implements PrintFlags {
|
||||
private readonly handlers = new Map<string, TableHandler>()
|
||||
private readonly noHeaders: boolean
|
||||
|
||||
constructor(opts: TablePrintFlagsOptions = {}) {
|
||||
this.noHeaders = opts.noHeaders ?? false
|
||||
}
|
||||
|
||||
register(handler: TableHandler, ...keys: string[]): void {
|
||||
for (const k of keys) this.handlers.set(k, handler)
|
||||
}
|
||||
|
||||
allowedFormats(): readonly string[] {
|
||||
return ALLOWED
|
||||
}
|
||||
|
||||
toPrinter(format: string): Printer {
|
||||
if (format !== '' && format !== 'wide')
|
||||
throw new NoCompatiblePrinterError(format, ALLOWED)
|
||||
const wide = format === 'wide'
|
||||
const handlers = this.handlers
|
||||
const noHeaders = this.noHeaders
|
||||
return {
|
||||
print(obj) {
|
||||
if (!isModer(obj))
|
||||
throw new Error('table printer: payload does not implement Moder')
|
||||
const mode = obj.mode()
|
||||
const handler = handlers.get(mode)
|
||||
if (handler === undefined) {
|
||||
const known = [...handlers.keys()].sort().join(', ')
|
||||
throw new Error(`table printer: no handler for mode "${mode}" (registered: ${known})`)
|
||||
}
|
||||
const cols = handler.columns()
|
||||
const keep: number[] = []
|
||||
for (let i = 0; i < cols.length; i++) {
|
||||
const col = cols[i]
|
||||
if (col !== undefined && (col.priority === 0 || wide))
|
||||
keep.push(i)
|
||||
}
|
||||
const rows = handler.rows(payload(obj))
|
||||
const stringRows: string[][] = rows.map(row =>
|
||||
keep.map((idx) => {
|
||||
const cell = row[idx]
|
||||
return cell === null || cell === undefined ? '' : String(cell)
|
||||
}),
|
||||
)
|
||||
const allRows: string[][] = noHeaders
|
||||
? stringRows
|
||||
: [keep.map(i => cols[i]?.name ?? ''), ...stringRows]
|
||||
return formatTable(allRows)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTable(rows: readonly string[][]): string {
|
||||
if (rows.length === 0)
|
||||
return ''
|
||||
const colCount = rows[0]?.length ?? 0
|
||||
const widths: number[] = Array.from({ length: colCount }, () => 0)
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const cell = row[i] ?? ''
|
||||
if (cell.length > (widths[i] ?? 0))
|
||||
widths[i] = cell.length
|
||||
}
|
||||
}
|
||||
const lines = rows.map((row) => {
|
||||
const cells: string[] = []
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const cell = row[i] ?? ''
|
||||
const isLast = i === colCount - 1
|
||||
if (isLast) {
|
||||
cells.push(cell)
|
||||
}
|
||||
else {
|
||||
const pad = (widths[i] ?? 0) - cell.length + COLUMN_PADDING
|
||||
cells.push(cell + ' '.repeat(pad))
|
||||
}
|
||||
}
|
||||
return cells.join('')
|
||||
})
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
21
cli/src/printers/format-text.test.ts
Normal file
21
cli/src/printers/format-text.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TextPrintFlags } from './format-text'
|
||||
|
||||
describe('TextPrintFlags', () => {
|
||||
it('routes to handler by mode', () => {
|
||||
const f = new TextPrintFlags()
|
||||
f.register({ render: v => `chat:${(v as { x: string }).x}\n` }, 'chat')
|
||||
f.register({ render: v => `wf:${(v as { y: string }).y}\n` }, 'workflow')
|
||||
expect(f.toPrinter('').print({ mode: () => 'chat', raw: () => ({ x: '1' }) })).toBe('chat:1\n')
|
||||
expect(f.toPrinter('text').print({ mode: () => 'workflow', raw: () => ({ y: '2' }) })).toBe('wf:2\n')
|
||||
})
|
||||
|
||||
it('rejects unknown formats', () => {
|
||||
expect(() => new TextPrintFlags().toPrinter('json')).toThrow(/not supported/)
|
||||
})
|
||||
|
||||
it('errors on unregistered mode', () => {
|
||||
const f = new TextPrintFlags()
|
||||
expect(() => f.toPrinter('').print({ mode: () => 'agent', raw: () => ({}) })).toThrow(/no handler for mode/)
|
||||
})
|
||||
})
|
||||
39
cli/src/printers/format-text.ts
Normal file
39
cli/src/printers/format-text.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { Printer, PrintFlags } from './printer'
|
||||
import { isModer, NoCompatiblePrinterError, payload } from './printer'
|
||||
|
||||
const ALLOWED = ['', 'text'] as const
|
||||
|
||||
export type TextHandler = {
|
||||
render: (raw: unknown) => string
|
||||
}
|
||||
|
||||
export class TextPrintFlags implements PrintFlags {
|
||||
private readonly handlers = new Map<string, TextHandler>()
|
||||
|
||||
register(handler: TextHandler, ...keys: string[]): void {
|
||||
for (const k of keys) this.handlers.set(k, handler)
|
||||
}
|
||||
|
||||
allowedFormats(): readonly string[] {
|
||||
return ALLOWED
|
||||
}
|
||||
|
||||
toPrinter(format: string): Printer {
|
||||
if (format !== '' && format !== 'text')
|
||||
throw new NoCompatiblePrinterError(format, ALLOWED)
|
||||
const handlers = this.handlers
|
||||
return {
|
||||
print(obj) {
|
||||
if (!isModer(obj))
|
||||
throw new Error('text printer: payload does not implement Moder')
|
||||
const mode = obj.mode()
|
||||
const h = handlers.get(mode)
|
||||
if (h === undefined) {
|
||||
const known = [...handlers.keys()].sort().join(', ')
|
||||
throw new Error(`text printer: no handler for mode "${mode}" (registered: ${known})`)
|
||||
}
|
||||
return h.render(payload(obj))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
101
cli/src/printers/printer.test.ts
Normal file
101
cli/src/printers/printer.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isModer,
|
||||
isNoCompatiblePrinter,
|
||||
isRawObject,
|
||||
NoCompatiblePrinterError,
|
||||
payload,
|
||||
} from './printer'
|
||||
|
||||
describe('NoCompatiblePrinterError', () => {
|
||||
it('mentions format and allowed list when allowed is non-empty', () => {
|
||||
const err = new NoCompatiblePrinterError('xml', ['json', 'yaml'])
|
||||
expect(err.message).toContain('xml')
|
||||
expect(err.message).toContain('json')
|
||||
expect(err.message).toContain('yaml')
|
||||
})
|
||||
|
||||
it('mentions only format when allowed list is empty', () => {
|
||||
const err = new NoCompatiblePrinterError('xml', [])
|
||||
expect(err.message).toContain('xml')
|
||||
expect(err.message).toContain('not supported')
|
||||
expect(err.message).not.toContain('allowed')
|
||||
})
|
||||
|
||||
it('exposes format and allowed publicly for callers that branch on them', () => {
|
||||
const err = new NoCompatiblePrinterError('xml', ['json'])
|
||||
expect(err.format).toBe('xml')
|
||||
expect(err.allowed).toEqual(['json'])
|
||||
})
|
||||
|
||||
it('has a stable name for serialization', () => {
|
||||
const err = new NoCompatiblePrinterError('xml', [])
|
||||
expect(err.name).toBe('NoCompatiblePrinterError')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNoCompatiblePrinter', () => {
|
||||
it('matches NoCompatiblePrinterError instances', () => {
|
||||
expect(isNoCompatiblePrinter(new NoCompatiblePrinterError('xml', ['json']))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match plain Error', () => {
|
||||
expect(isNoCompatiblePrinter(new Error('other'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match a wrapped error message', () => {
|
||||
expect(isNoCompatiblePrinter(new Error('wrapped: output format "xml" not supported'))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match null/undefined/primitives', () => {
|
||||
expect(isNoCompatiblePrinter(null)).toBe(false)
|
||||
expect(isNoCompatiblePrinter(undefined)).toBe(false)
|
||||
expect(isNoCompatiblePrinter('string')).toBe(false)
|
||||
expect(isNoCompatiblePrinter(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRawObject', () => {
|
||||
it('detects objects exposing raw()', () => {
|
||||
expect(isRawObject({ raw: () => 42 })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects values without raw()', () => {
|
||||
expect(isRawObject({})).toBe(false)
|
||||
expect(isRawObject(null)).toBe(false)
|
||||
expect(isRawObject(undefined)).toBe(false)
|
||||
expect(isRawObject(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects objects where raw is not callable', () => {
|
||||
expect(isRawObject({ raw: 42 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isModer', () => {
|
||||
it('detects objects exposing mode()', () => {
|
||||
expect(isModer({ mode: () => 'chat' })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects values without mode()', () => {
|
||||
expect(isModer({})).toBe(false)
|
||||
expect(isModer(null)).toBe(false)
|
||||
expect(isModer({ mode: 'chat' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload', () => {
|
||||
it('unwraps RawObject via raw()', () => {
|
||||
expect(payload({ raw: () => ({ id: 'a' }) })).toEqual({ id: 'a' })
|
||||
})
|
||||
|
||||
it('returns the value as-is when it is not a RawObject', () => {
|
||||
const obj = { id: 'a' }
|
||||
expect(payload(obj)).toBe(obj)
|
||||
})
|
||||
|
||||
it('returns primitives untouched', () => {
|
||||
expect(payload(42)).toBe(42)
|
||||
expect(payload(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
82
cli/src/printers/printer.ts
Normal file
82
cli/src/printers/printer.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export type Format = '' | 'wide' | 'json' | 'yaml' | 'name'
|
||||
|
||||
export type Printer = {
|
||||
print: (obj: unknown) => string
|
||||
}
|
||||
|
||||
export type RawObject = {
|
||||
raw: () => unknown
|
||||
}
|
||||
|
||||
export type Moder = {
|
||||
mode: () => string
|
||||
}
|
||||
|
||||
export type PrintFlags = {
|
||||
allowedFormats: () => readonly string[]
|
||||
toPrinter: (format: string) => Printer
|
||||
}
|
||||
|
||||
export class NoCompatiblePrinterError extends Error {
|
||||
override readonly name = 'NoCompatiblePrinterError'
|
||||
readonly format: string
|
||||
readonly allowed: readonly string[]
|
||||
|
||||
constructor(format: string, allowed: readonly string[]) {
|
||||
super(
|
||||
allowed.length === 0
|
||||
? `output format ${JSON.stringify(format)} not supported`
|
||||
: `output format ${JSON.stringify(format)} not supported, allowed: ${allowed.join(', ')}`,
|
||||
)
|
||||
this.format = format
|
||||
this.allowed = allowed
|
||||
}
|
||||
}
|
||||
|
||||
export function isNoCompatiblePrinter(err: unknown): err is NoCompatiblePrinterError {
|
||||
return err instanceof NoCompatiblePrinterError
|
||||
}
|
||||
|
||||
export abstract class CompositePrintFlags implements PrintFlags {
|
||||
protected abstract families(): readonly PrintFlags[]
|
||||
|
||||
allowedFormats(): readonly string[] {
|
||||
const seen = new Set<string>()
|
||||
for (const fam of this.families()) {
|
||||
for (const f of fam.allowedFormats()) {
|
||||
if (f !== '')
|
||||
seen.add(f)
|
||||
}
|
||||
}
|
||||
return [...seen].sort()
|
||||
}
|
||||
|
||||
toPrinter(format: string): Printer {
|
||||
for (const fam of this.families()) {
|
||||
try {
|
||||
return fam.toPrinter(format)
|
||||
}
|
||||
catch (err) {
|
||||
if (!isNoCompatiblePrinter(err))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
throw new NoCompatiblePrinterError(format, this.allowedFormats())
|
||||
}
|
||||
}
|
||||
|
||||
export function isRawObject(v: unknown): v is RawObject {
|
||||
return typeof v === 'object'
|
||||
&& v !== null
|
||||
&& typeof (v as { raw?: unknown }).raw === 'function'
|
||||
}
|
||||
|
||||
export function isModer(v: unknown): v is Moder {
|
||||
return typeof v === 'object'
|
||||
&& v !== null
|
||||
&& typeof (v as { mode?: unknown }).mode === 'function'
|
||||
}
|
||||
|
||||
export function payload(obj: unknown): unknown {
|
||||
return isRawObject(obj) ? obj.raw() : obj
|
||||
}
|
||||
76
cli/src/printers/width.test.ts
Normal file
76
cli/src/printers/width.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { TERMINAL_WIDTH_FALLBACK, terminalWidth, truncate } from './width'
|
||||
|
||||
describe('truncate', () => {
|
||||
it('returns the input unchanged when shorter than max', () => {
|
||||
expect(truncate('hi', 5)).toBe('hi')
|
||||
})
|
||||
|
||||
it('returns the input unchanged when exactly at max', () => {
|
||||
expect(truncate('hello', 5)).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates to max with single ellipsis char when longer', () => {
|
||||
expect(truncate('hello world', 5)).toBe('hell…')
|
||||
})
|
||||
|
||||
it('returns empty for empty input regardless of max', () => {
|
||||
expect(truncate('', 5)).toBe('')
|
||||
})
|
||||
|
||||
it('returns just the ellipsis when max is 1', () => {
|
||||
expect(truncate('hello', 1)).toBe('…')
|
||||
})
|
||||
|
||||
it('returns empty when max is 0', () => {
|
||||
expect(truncate('hello', 0)).toBe('')
|
||||
})
|
||||
|
||||
it('handles negative max gracefully', () => {
|
||||
expect(truncate('hello', -3)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminalWidth', () => {
|
||||
let originalColumns: number | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalColumns = process.stdout.columns
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: originalColumns,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns process.stdout.columns when present', () => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: 120,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
expect(terminalWidth()).toBe(120)
|
||||
})
|
||||
|
||||
it('falls back to 80 when columns is undefined', () => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK)
|
||||
expect(TERMINAL_WIDTH_FALLBACK).toBe(80)
|
||||
})
|
||||
|
||||
it('falls back to 80 when columns is 0', () => {
|
||||
Object.defineProperty(process.stdout, 'columns', {
|
||||
value: 0,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
expect(terminalWidth()).toBe(TERMINAL_WIDTH_FALLBACK)
|
||||
})
|
||||
})
|
||||
17
cli/src/printers/width.ts
Normal file
17
cli/src/printers/width.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const TERMINAL_WIDTH_FALLBACK = 80
|
||||
const ELLIPSIS = '…'
|
||||
|
||||
export function terminalWidth(): number {
|
||||
const cols = process.stdout.columns
|
||||
return typeof cols === 'number' && cols > 0 ? cols : TERMINAL_WIDTH_FALLBACK
|
||||
}
|
||||
|
||||
export function truncate(s: string, max: number): string {
|
||||
if (s === '' || max <= 0)
|
||||
return ''
|
||||
if (s.length <= max)
|
||||
return s
|
||||
if (max === 1)
|
||||
return ELLIPSIS
|
||||
return s.slice(0, max - 1) + ELLIPSIS
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectFromList } from './select'
|
||||
import { bufferStreams } from './streams'
|
||||
|
||||
type Row = { id: string, label: string }
|
||||
const rows: Row[] = [
|
||||
{ id: '1', label: 'alpha' },
|
||||
{ id: '2', label: 'beta' },
|
||||
{ id: '3', label: 'gamma' },
|
||||
]
|
||||
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
|
||||
type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown }
|
||||
|
||||
function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn {
|
||||
const stream = new PassThrough() as unknown as FakeTTYIn
|
||||
stream.isTTY = true
|
||||
stream.isRaw = false
|
||||
stream.setRawMode = (mode: boolean): unknown => {
|
||||
if (opts.failRawMode === true && mode)
|
||||
throw new Error('raw mode unavailable')
|
||||
stream.isRaw = mode
|
||||
return stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> {
|
||||
const io = bufferStreams()
|
||||
;(io as { in: NodeJS.ReadableStream }).in = input
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = true
|
||||
return io
|
||||
}
|
||||
|
||||
describe('selectFromList (non-TTY numbered fallback)', () => {
|
||||
it('returns the item matching the typed number', async () => {
|
||||
const io = bufferStreams('2\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label })
|
||||
expect(picked.id).toBe('2')
|
||||
expect(io.errBuf()).toContain('1) alpha')
|
||||
expect(io.errBuf()).toContain('Pick one')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range selection', async () => {
|
||||
const io = bufferStreams('9\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/invalid selection/i)
|
||||
})
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const io = bufferStreams('1\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label }))
|
||||
.rejects
|
||||
.toThrow(/nothing to select/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFromList (interactive TTY picker)', () => {
|
||||
it('moves with arrow keys and resolves on enter, restoring raw mode', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B[B')
|
||||
input.write('\r')
|
||||
const picked = await pick
|
||||
expect(picked.id).toBe('2')
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
|
||||
it('cancels on escape', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B')
|
||||
await expect(pick).rejects.toThrow(/cancelled/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects and restores the terminal when raw-mode setup fails', async () => {
|
||||
const input = ttyInput({ failRawMode: true })
|
||||
const io = ttyStreams(input)
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/raw mode unavailable/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
})
|
||||
@ -1,153 +0,0 @@
|
||||
import type { Key } from 'node:readline'
|
||||
import type { IOStreams } from './streams'
|
||||
import * as readline from 'node:readline'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { colorEnabled, colorScheme } from './color'
|
||||
|
||||
export type SelectOptions<T> = {
|
||||
readonly io: IOStreams
|
||||
readonly items: readonly T[]
|
||||
readonly header: string
|
||||
/** Single rich line shown per option. */
|
||||
readonly render: (item: T) => string
|
||||
/** Optional second line shown only for the focused option in the TTY picker. */
|
||||
readonly describe?: (item: T) => string
|
||||
}
|
||||
|
||||
const HIDE_CURSOR = '\x1B[?25l'
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
const CLEAR_DOWN = '\x1B[0J'
|
||||
const cursorUp = (n: number): string => `\x1B[${n}A`
|
||||
|
||||
export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
if (opts.items.length === 0)
|
||||
throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' })
|
||||
return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-key picker built on Node's readline keypress events — no third-party
|
||||
* prompt library, so it bundles cleanly into the compiled binary. Renders to
|
||||
* the err stream, redrawing in place on each keystroke and erasing itself on
|
||||
* exit so the caller's own output starts on a clean row.
|
||||
*/
|
||||
async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
const input = opts.io.in as NodeJS.ReadStream
|
||||
const out = opts.io.err
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const count = opts.items.length
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = 0
|
||||
let rendered = 0
|
||||
|
||||
const frame = (): readonly string[] => {
|
||||
const lines = [opts.header]
|
||||
opts.items.forEach((item, i) => {
|
||||
const focused = i === active
|
||||
const pointer = focused ? cs.cyan('❯') : ' '
|
||||
const label = focused ? cs.bold(opts.render(item)) : opts.render(item)
|
||||
lines.push(`${pointer} ${label}`)
|
||||
})
|
||||
const desc = opts.describe?.(opts.items[active] as T)
|
||||
if (desc !== undefined && desc !== '')
|
||||
lines.push(cs.dim(` ${desc}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
const render = (): void => {
|
||||
if (rendered > 0)
|
||||
out.write(cursorUp(rendered))
|
||||
const lines = frame()
|
||||
out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`)
|
||||
rendered = lines.length
|
||||
}
|
||||
|
||||
const wasRaw = input.isTTY ? input.isRaw : false
|
||||
const cleanup = (): void => {
|
||||
input.off('keypress', onKey)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(wasRaw)
|
||||
input.pause()
|
||||
if (rendered > 0)
|
||||
out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`)
|
||||
out.write(SHOW_CURSOR)
|
||||
}
|
||||
|
||||
function onKey(_str: string | undefined, key: Key): void {
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
return
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'up':
|
||||
case 'k':
|
||||
active = (active - 1 + count) % count
|
||||
render()
|
||||
break
|
||||
case 'down':
|
||||
case 'j':
|
||||
active = (active + 1) % count
|
||||
render()
|
||||
break
|
||||
case 'return':
|
||||
case 'enter': {
|
||||
const chosen = opts.items[active]
|
||||
cleanup()
|
||||
if (chosen === undefined)
|
||||
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }))
|
||||
else
|
||||
resolve(chosen)
|
||||
break
|
||||
}
|
||||
case 'escape':
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
readline.emitKeypressEvents(input)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(true)
|
||||
out.write(HIDE_CURSOR)
|
||||
input.on('keypress', onKey)
|
||||
input.resume()
|
||||
render()
|
||||
}
|
||||
catch (err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelled(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' })
|
||||
}
|
||||
|
||||
async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
opts.io.err.write(`${opts.header}\n`)
|
||||
opts.items.forEach((item, idx) => {
|
||||
opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`)
|
||||
})
|
||||
opts.io.err.write('Enter number: ')
|
||||
|
||||
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
|
||||
try {
|
||||
const line: string = await new Promise(resolve => rl.once('line', resolve))
|
||||
const n = Number(line.trim())
|
||||
const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined
|
||||
if (chosen === undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` })
|
||||
return chosen
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,30 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { saveHosts } from '@/auth/hosts'
|
||||
import { ENV_CONFIG_DIR } from '@/store/dir'
|
||||
import { arch } from '@/sys/index'
|
||||
import { runVersionProbe } from './probe'
|
||||
|
||||
function active(overrides: Partial<ActiveContext> = {}): ActiveContext {
|
||||
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
|
||||
return {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'test@dify.ai',
|
||||
ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } },
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
...overrides,
|
||||
}
|
||||
} as HostsBundle
|
||||
}
|
||||
|
||||
describe('runVersionProbe', () => {
|
||||
it('returns skipped server + unknown compat when skipServer=true', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -38,7 +38,7 @@ describe('runVersionProbe', () => {
|
||||
let observed: string | undefined
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }),
|
||||
probe: async (endpoint) => {
|
||||
observed = endpoint
|
||||
return { version: '1.6.4', edition: 'CLOUD' }
|
||||
@ -49,10 +49,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns no-host + unknown compat when active context is missing', async () => {
|
||||
it('returns no-host + unknown compat when bundle is missing', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -61,10 +61,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('no host')
|
||||
})
|
||||
|
||||
it('returns no-host when active context has empty host', async () => {
|
||||
it('returns no-host when bundle has empty current_host', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active({ host: '' }),
|
||||
loadBundle: async () => bundle({ current_host: '' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -72,10 +72,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('distinguishes loadActive disk failure from no-host configured in the detail', async () => {
|
||||
it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => {
|
||||
const errReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => { throw new Error('disk-explode') },
|
||||
loadBundle: async () => { throw new Error('disk-explode') },
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(errReport.server.reachable).toBe(false)
|
||||
@ -84,7 +84,7 @@ describe('runVersionProbe', () => {
|
||||
|
||||
const noHostReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(noHostReport.compat.detail).toContain('no host')
|
||||
@ -94,7 +94,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns compatible report when server is reachable and in range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -108,7 +108,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -119,7 +119,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -130,7 +130,7 @@ describe('runVersionProbe', () => {
|
||||
it('treats probe rejection as unreachable + unknown compat', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active(),
|
||||
loadBundle: async () => bundle(),
|
||||
probe: async () => { throw new Error('timeout') },
|
||||
})
|
||||
|
||||
@ -141,10 +141,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('builds endpoint using active scheme when host has no scheme', async () => {
|
||||
it('builds endpoint using bundle scheme when host has no scheme', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }),
|
||||
loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -161,12 +161,12 @@ describe('runVersionProbe', () => {
|
||||
const prevConfig = process.env[ENV_CONFIG_DIR]
|
||||
try {
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } })
|
||||
reg.setHost(url.host)
|
||||
reg.setAccount('test@dify.ai')
|
||||
reg.setScheme(url.host, url.protocol.replace(':', ''))
|
||||
reg.save()
|
||||
saveHosts({
|
||||
current_host: url.host,
|
||||
scheme: url.protocol.replace(':', ''),
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
|
||||
const report = await runVersionProbe({ skipServer: false })
|
||||
@ -190,7 +190,7 @@ describe('runVersionProbe', () => {
|
||||
it('always includes client metadata in the report', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadActive: async () => undefined,
|
||||
loadBundle: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { CompatVerdict } from './compat'
|
||||
import type { Channel } from './info'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '@/api/meta'
|
||||
import { Registry } from '@/auth/hosts'
|
||||
import { loadHosts } from '@/auth/hosts'
|
||||
import { createClient } from '@/http/client'
|
||||
import { arch, platform } from '@/sys/index'
|
||||
import { hostWithScheme } from '@/util/host'
|
||||
@ -43,13 +43,11 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse>
|
||||
|
||||
export type RunVersionProbeOptions = {
|
||||
readonly skipServer: boolean
|
||||
readonly loadActive?: () => Promise<ActiveContext | undefined>
|
||||
readonly loadBundle?: () => Promise<HostsBundle | undefined>
|
||||
readonly probe?: MetaProbe
|
||||
}
|
||||
|
||||
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
|
||||
return Registry.load().resolveActive()
|
||||
}
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
@ -91,19 +89,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const loadActive = opts.loadActive ?? defaultLoadActive
|
||||
const loadBundle = opts.loadBundle ?? defaultLoadBundle
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
|
||||
let active: ActiveContext | undefined
|
||||
let bundle: HostsBundle | undefined
|
||||
let loadFailed = false
|
||||
try {
|
||||
active = await loadActive()
|
||||
bundle = await loadBundle()
|
||||
}
|
||||
catch {
|
||||
loadFailed = true
|
||||
}
|
||||
|
||||
if (active === undefined || active.host === '') {
|
||||
if (bundle === undefined || bundle.current_host === '') {
|
||||
const detail = loadFailed ? 'hosts file unreadable' : 'no host configured'
|
||||
return {
|
||||
client,
|
||||
@ -112,7 +110,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = hostWithScheme(active.host, active.scheme)
|
||||
const endpoint = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
|
||||
let serverInfo: ServerVersionResponse | undefined
|
||||
try {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HostsBundle } from '@/auth/hosts'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
export type WorkspaceResolveInputs = {
|
||||
readonly flag?: string
|
||||
readonly env?: string
|
||||
readonly active?: ActiveContext
|
||||
readonly bundle?: HostsBundle
|
||||
}
|
||||
|
||||
export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
return inputs.flag
|
||||
if (truthy(inputs.env))
|
||||
return inputs.env
|
||||
const ctx = inputs.active?.ctx
|
||||
if (ctx !== undefined) {
|
||||
if (truthy(ctx.workspace?.id))
|
||||
return ctx.workspace.id
|
||||
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
|
||||
&& truthy(ctx.available_workspaces[0]?.id)) {
|
||||
return ctx.available_workspaces[0].id
|
||||
const b = inputs.bundle
|
||||
if (b !== undefined) {
|
||||
if (truthy(b.workspace?.id))
|
||||
return b.workspace.id
|
||||
if (b.available_workspaces !== undefined && b.available_workspaces.length > 0
|
||||
&& truthy(b.available_workspaces[0]?.id)) {
|
||||
return b.available_workspaces[0].id
|
||||
}
|
||||
}
|
||||
throw new BaseError({
|
||||
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -1,7 +1,6 @@
|
||||
export type Scenario
|
||||
= | 'happy'
|
||||
| 'sso'
|
||||
| 'no-email'
|
||||
| 'denied'
|
||||
| 'expired'
|
||||
| 'auth-expired'
|
||||
|
||||
10
cli/test/fixtures/dify-mock/server.ts
vendored
10
cli/test/fixtures/dify-mock/server.ts
vendored
@ -362,16 +362,6 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
if (scenario === 'no-email') {
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
account: { id: ACCOUNT.id, email: '', name: '' },
|
||||
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
default_workspace_id: 'ws-1',
|
||||
token_id: 'tok-1',
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
|
||||
@ -22,6 +22,10 @@ export default antfu(
|
||||
'!packages/**',
|
||||
'!web/**',
|
||||
'!e2e/**',
|
||||
'!cli/**',
|
||||
'cli/context/**',
|
||||
'cli/docs/**',
|
||||
'cli/oclif.manifest.json',
|
||||
'!eslint.config.mjs',
|
||||
'!package.json',
|
||||
'!pnpm-workspace.yaml',
|
||||
@ -74,4 +78,17 @@ export default antfu(
|
||||
'node/prefer-global/process': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['cli/src/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: ['../**', './*/**', '..'],
|
||||
message: 'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
276
pnpm-lock.yaml
generated
276
pnpm-lock.yaml
generated
@ -162,15 +162,24 @@ catalogs:
|
||||
'@tanstack/eslint-plugin-query':
|
||||
specifier: 5.100.14
|
||||
version: 5.100.14
|
||||
'@tanstack/react-devtools':
|
||||
specifier: 0.10.5
|
||||
version: 0.10.5
|
||||
'@tanstack/react-form':
|
||||
specifier: 1.32.0
|
||||
version: 1.32.0
|
||||
'@tanstack/react-form-devtools':
|
||||
specifier: 0.2.27
|
||||
version: 0.2.27
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.100.14
|
||||
version: 5.100.14
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: 5.100.14
|
||||
version: 5.100.14
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.13.25
|
||||
version: 3.13.25
|
||||
@ -1363,6 +1372,15 @@ importers:
|
||||
'@tanstack/eslint-plugin-query':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.14(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
'@tanstack/react-devtools':
|
||||
specifier: 'catalog:'
|
||||
version: 0.10.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-form-devtools':
|
||||
specifier: 'catalog:'
|
||||
version: 0.2.27(@types/react@19.2.15)(csstype@3.2.3)(react@19.2.6)(solid-js@1.9.13)
|
||||
'@tanstack/react-query-devtools':
|
||||
specifier: 'catalog:'
|
||||
version: 5.100.14(@tanstack/react-query@5.100.14(react@19.2.6))(react@19.2.6)
|
||||
'@testing-library/dom':
|
||||
specifier: 'catalog:'
|
||||
version: 10.4.1
|
||||
@ -4110,6 +4128,36 @@ packages:
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@solid-primitives/event-listener@2.4.5':
|
||||
resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/keyboard@1.3.5':
|
||||
resolution: {integrity: sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/resize-observer@2.1.5':
|
||||
resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/rootless@1.5.3':
|
||||
resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/static-store@0.1.3':
|
||||
resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/utils@6.4.0':
|
||||
resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
@ -4391,11 +4439,50 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@tanstack/devtools-client@0.0.6':
|
||||
resolution: {integrity: sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/devtools-event-bus@0.4.1':
|
||||
resolution: {integrity: sha512-cNnJ89Q021Zf883rlbBTfsaxTfi2r73/qejGtyTa7ksErF3hyDyAq1aTbo5crK9dAL7zSHh9viKY1BtMls1QOA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/devtools-event-client@0.4.3':
|
||||
resolution: {integrity: sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@tanstack/devtools-ui@0.5.2':
|
||||
resolution: {integrity: sha512-GtaMk8kaGZ9ZdR8Pu5RAfcse/ZrxzH/xsAIFtHMapLs2VMqSPFfb1NvIDO1MAAfUcub8Ix8XKQEP0uYSPzoFKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/devtools-utils@0.4.0':
|
||||
resolution: {integrity: sha512-KsGzYhA8L/fCNgyyMyoUy+TKtx+DjNbzWwqH6wXL48Llzo7kvV9RynYJlaO8Qkzwm+NdHXSgsljQNjQ3CKPpZA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/react': '>=17.0.0'
|
||||
preact: '>=10.0.0'
|
||||
react: '>=17.0.0'
|
||||
solid-js: 1.9.13
|
||||
vue: '>=3.2.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
preact:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
solid-js:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@tanstack/devtools@0.12.2':
|
||||
resolution: {integrity: sha512-Xdl8pLzoDUvXaclQ0poY36WAPx0jEHk8vqUFd8FYFUm1BMshtB7RnTgD1HE9jCAXODxqw9I0gXBiUZLK3o3+Bw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@tanstack/eslint-plugin-query@5.100.14':
|
||||
resolution: {integrity: sha512-NbpiBCmeHTRuVHeV5+U+1bzmxyTW5Dzp2sCeE6Hx+ZJTJWFK9dsm8VZmRc7LQP9/ZORsF620PvgUk67AwiBo4A==}
|
||||
peerDependencies:
|
||||
@ -4408,6 +4495,11 @@ packages:
|
||||
'@tanstack/form-core@1.32.0':
|
||||
resolution: {integrity: sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow==}
|
||||
|
||||
'@tanstack/form-devtools@0.2.27':
|
||||
resolution: {integrity: sha512-h91foKH1RtUwJC1079kwcNQlO1LYBKdIY5n7kHGGjNTQiH0wLSIBJnxZnOxOJ1qaSjErTqJw5e9sIj3O+zyRYg==}
|
||||
peerDependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
resolution: {integrity: sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -4419,6 +4511,23 @@ packages:
|
||||
'@tanstack/query-core@5.100.14':
|
||||
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
|
||||
|
||||
'@tanstack/query-devtools@5.100.14':
|
||||
resolution: {integrity: sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==}
|
||||
|
||||
'@tanstack/react-devtools@0.10.5':
|
||||
resolution: {integrity: sha512-orVsRJ7oAXFb7oyafQCgx9YuK44jpILh5T/ddYuxAsolNfN5DZBr5/NLrWErD7HCGIzvYzg1TZI4sPxmiKvtvA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
'@types/react-dom': '>=16.8'
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-form-devtools@0.2.27':
|
||||
resolution: {integrity: sha512-c7ugxpwxPBrT2nMYFPaW448SVxYvdi+ruPU/UcFS6YYdQ5uJGqjOJDF+ObCHWxlNJQWIXQVIeqbDZ8UaNPOLTA==}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-form@1.32.0':
|
||||
resolution: {integrity: sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw==}
|
||||
peerDependencies:
|
||||
@ -4435,6 +4544,12 @@ packages:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.14':
|
||||
resolution: {integrity: sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==}
|
||||
peerDependencies:
|
||||
'@tanstack/react-query': ^5.100.14
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-query@5.100.14':
|
||||
resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==}
|
||||
peerDependencies:
|
||||
@ -6531,6 +6646,11 @@ packages:
|
||||
globrex@0.1.2:
|
||||
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
|
||||
|
||||
goober@2.1.18:
|
||||
resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==}
|
||||
peerDependencies:
|
||||
csstype: ^3.0.10
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@ -8292,6 +8412,16 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
seroval-plugins@1.5.1:
|
||||
resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
seroval: ^1.0
|
||||
|
||||
seroval@1.5.1:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
@ -8350,6 +8480,9 @@ packages:
|
||||
resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
solid-js@1.9.13:
|
||||
resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==}
|
||||
|
||||
sortablejs@1.15.7:
|
||||
resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
|
||||
|
||||
@ -11577,6 +11710,40 @@ snapshots:
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@solid-primitives/event-listener@2.4.5(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.13)
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/keyboard@1.3.5(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13)
|
||||
'@solid-primitives/rootless': 1.5.3(solid-js@1.9.13)
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.13)
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/resize-observer@2.1.5(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13)
|
||||
'@solid-primitives/rootless': 1.5.3(solid-js@1.9.13)
|
||||
'@solid-primitives/static-store': 0.1.3(solid-js@1.9.13)
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.13)
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/rootless@1.5.3(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.13)
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/static-store@0.1.3(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@solid-primitives/utils': 6.4.0(solid-js@1.9.13)
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@solid-primitives/utils@6.4.0(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
@ -11836,8 +12003,50 @@ snapshots:
|
||||
tailwindcss: 4.3.0
|
||||
vite: '@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.27.2)(jiti@2.7.0)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0)'
|
||||
|
||||
'@tanstack/devtools-client@0.0.6':
|
||||
dependencies:
|
||||
'@tanstack/devtools-event-client': 0.4.3
|
||||
|
||||
'@tanstack/devtools-event-bus@0.4.1':
|
||||
dependencies:
|
||||
ws: 8.21.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@tanstack/devtools-event-client@0.4.3': {}
|
||||
|
||||
'@tanstack/devtools-ui@0.5.2(csstype@3.2.3)':
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.20
|
||||
goober: 2.1.18(csstype@3.2.3)
|
||||
solid-js: 1.9.13
|
||||
transitivePeerDependencies:
|
||||
- csstype
|
||||
|
||||
'@tanstack/devtools-utils@0.4.0(@types/react@19.2.15)(react@19.2.6)(solid-js@1.9.13)':
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.15
|
||||
react: 19.2.6
|
||||
solid-js: 1.9.13
|
||||
|
||||
'@tanstack/devtools@0.12.2(csstype@3.2.3)':
|
||||
dependencies:
|
||||
'@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13)
|
||||
'@solid-primitives/keyboard': 1.3.5(solid-js@1.9.13)
|
||||
'@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.13)
|
||||
'@tanstack/devtools-client': 0.0.6
|
||||
'@tanstack/devtools-event-bus': 0.4.1
|
||||
'@tanstack/devtools-ui': 0.5.2(csstype@3.2.3)
|
||||
clsx: 2.1.1
|
||||
goober: 2.1.18(csstype@3.2.3)
|
||||
solid-js: 1.9.13
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- csstype
|
||||
- utf-8-validate
|
||||
|
||||
'@tanstack/eslint-plugin-query@5.100.14(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@typescript-eslint/utils': 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
@ -11853,6 +12062,22 @@ snapshots:
|
||||
'@tanstack/pacer-lite': 0.1.1
|
||||
'@tanstack/store': 0.9.3
|
||||
|
||||
'@tanstack/form-devtools@0.2.27(@types/react@19.2.15)(csstype@3.2.3)(react@19.2.6)(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@tanstack/devtools-ui': 0.5.2(csstype@3.2.3)
|
||||
'@tanstack/devtools-utils': 0.4.0(@types/react@19.2.15)(react@19.2.6)(solid-js@1.9.13)
|
||||
'@tanstack/form-core': 1.32.0
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.20
|
||||
goober: 2.1.18(csstype@3.2.3)
|
||||
solid-js: 1.9.13
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- csstype
|
||||
- preact
|
||||
- react
|
||||
- vue
|
||||
|
||||
'@tanstack/hotkeys@0.8.0':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.11.0
|
||||
@ -11861,6 +12086,32 @@ snapshots:
|
||||
|
||||
'@tanstack/query-core@5.100.14': {}
|
||||
|
||||
'@tanstack/query-devtools@5.100.14': {}
|
||||
|
||||
'@tanstack/react-devtools@0.10.5(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(csstype@3.2.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/devtools': 0.12.2(csstype@3.2.3)
|
||||
'@types/react': 19.2.15
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.15)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- csstype
|
||||
- utf-8-validate
|
||||
|
||||
'@tanstack/react-form-devtools@0.2.27(@types/react@19.2.15)(csstype@3.2.3)(react@19.2.6)(solid-js@1.9.13)':
|
||||
dependencies:
|
||||
'@tanstack/devtools-utils': 0.4.0(@types/react@19.2.15)(react@19.2.6)(solid-js@1.9.13)
|
||||
'@tanstack/form-devtools': 0.2.27(@types/react@19.2.15)(csstype@3.2.3)(react@19.2.6)(solid-js@1.9.13)
|
||||
react: 19.2.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- csstype
|
||||
- preact
|
||||
- solid-js
|
||||
- vue
|
||||
|
||||
'@tanstack/react-form@1.32.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/form-core': 1.32.0
|
||||
@ -11876,6 +12127,12 @@ snapshots:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@tanstack/react-query-devtools@5.100.14(@tanstack/react-query@5.100.14(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/query-devtools': 5.100.14
|
||||
'@tanstack/react-query': 5.100.14(react@19.2.6)
|
||||
react: 19.2.6
|
||||
|
||||
'@tanstack/react-query@5.100.14(react@19.2.6)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.14
|
||||
@ -14219,6 +14476,10 @@ snapshots:
|
||||
|
||||
globrex@0.1.2: {}
|
||||
|
||||
goober@2.1.18(csstype@3.2.3):
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
h3@2.0.1-rc.22:
|
||||
@ -16386,6 +16647,12 @@ snapshots:
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
seroval-plugins@1.5.1(seroval@1.5.1):
|
||||
dependencies:
|
||||
seroval: 1.5.1
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
@ -16482,6 +16749,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
solid-js@1.9.13:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
seroval: 1.5.1
|
||||
seroval-plugins: 1.5.1(seroval@1.5.1)
|
||||
|
||||
sortablejs@1.15.7: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
@ -17380,8 +17653,11 @@ time:
|
||||
'@tailwindcss/typography@0.5.19': '2025-09-24T14:49:08.735Z'
|
||||
'@tailwindcss/vite@4.3.0': '2026-05-08T20:25:50.670Z'
|
||||
'@tanstack/eslint-plugin-query@5.100.14': '2026-05-23T17:16:09.131Z'
|
||||
'@tanstack/react-devtools@0.10.5': '2026-05-13T10:21:52.170Z'
|
||||
'@tanstack/react-form-devtools@0.2.27': '2026-05-10T22:35:01.854Z'
|
||||
'@tanstack/react-form@1.32.0': '2026-05-10T22:35:03.027Z'
|
||||
'@tanstack/react-hotkeys@0.10.0': '2026-04-25T12:28:06.989Z'
|
||||
'@tanstack/react-query-devtools@5.100.14': '2026-05-23T17:16:15.324Z'
|
||||
'@tanstack/react-query@5.100.14': '2026-05-23T17:16:15.371Z'
|
||||
'@tanstack/react-virtual@3.13.25': '2026-05-20T20:14:23.050Z'
|
||||
'@testing-library/dom@10.4.1': '2025-07-27T13:23:37.151Z'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user