mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 13:47:52 +08:00
Compare commits
3 Commits
main
...
fix/cli-to
| Author | SHA1 | Date | |
|---|---|---|---|
| e4c98692f7 | |||
| 2fc6febb3f | |||
| 0bbe60beb4 |
@ -467,8 +467,7 @@ class AppListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session):
|
||||
def get(self):
|
||||
"""Get app list"""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
@ -505,7 +504,7 @@ class AppListApi(Resource):
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
db.session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
|
||||
@ -2,7 +2,6 @@ from collections.abc import Sequence
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -12,7 +11,6 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -21,6 +19,7 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
@ -159,8 +158,7 @@ class InstructionGenerateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
def post(self, current_tenant_id: str):
|
||||
args = InstructionGeneratePayload.model_validate(console_ns.payload)
|
||||
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
|
||||
code_provider: type[CodeNodeProvider] | None = next(
|
||||
@ -170,10 +168,10 @@ class InstructionGenerateApi(Resource):
|
||||
try:
|
||||
# Generate from nothing for a workflow node
|
||||
if (args.current in (code_template, "")) and args.node_id != "":
|
||||
app = session.get(App, args.flow_id)
|
||||
app = db.session.get(App, args.flow_id)
|
||||
if not app:
|
||||
return {"error": f"app {args.flow_id} not found"}, 400
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
if not workflow:
|
||||
return {"error": f"workflow {args.flow_id} not found"}, 400
|
||||
nodes: Sequence = workflow.graph_dict["nodes"]
|
||||
|
||||
@ -140,21 +140,14 @@ class WorkflowService:
|
||||
)
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
def get_draft_workflow(
|
||||
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
|
||||
) -> Workflow | None:
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
"""
|
||||
Get draft workflow
|
||||
|
||||
When ``session`` is provided, reuse it so callers that already hold a
|
||||
Session avoid checking out an extra request-scoped ``db.session``
|
||||
connection. Falls back to ``db.session`` for backward compatibility.
|
||||
"""
|
||||
if workflow_id:
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id, session=session)
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id)
|
||||
# fetch draft workflow by app_model
|
||||
bind = session if session is not None else db.session
|
||||
workflow = bind.scalar(
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
|
||||
@ -7,7 +7,6 @@ from importlib import util
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask.views import MethodView
|
||||
@ -19,15 +18,6 @@ if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
bound_self = getattr(func, "__self__", None)
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
if bound_self is not None:
|
||||
return func.__get__(bound_self, bound_self.__class__)
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_module():
|
||||
module_name = "controllers.console.app.app"
|
||||
@ -405,46 +395,3 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
|
||||
assert len(serialized["data"]) == 2
|
||||
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
|
||||
assert serialized["data"][1]["icon_url"] is None
|
||||
|
||||
|
||||
def test_app_list_uses_injected_session_for_draft_workflows(app, app_module, monkeypatch):
|
||||
api = app_module.AppListApi()
|
||||
method = _unwrap(api.get)
|
||||
current_user = SimpleNamespace(id="user-1")
|
||||
app_item = SimpleNamespace(
|
||||
id="app-1",
|
||||
name="Workflow App",
|
||||
desc_or_prompt="Summary",
|
||||
mode="workflow",
|
||||
mode_compatible_with_agent="workflow",
|
||||
)
|
||||
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-1",
|
||||
app_id="app-1",
|
||||
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
|
||||
scoped_session = SimpleNamespace(execute=MagicMock(side_effect=AssertionError("db.session should not be used")))
|
||||
|
||||
monkeypatch.setattr(app_module, "current_account_with_tenant", lambda: (current_user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"AppService",
|
||||
lambda: SimpleNamespace(get_paginate_apps=lambda *_args, **_kwargs: app_pagination),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"FeatureService",
|
||||
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
|
||||
)
|
||||
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
|
||||
|
||||
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
|
||||
response, status = method(session)
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0]["has_draft_trigger"] is True
|
||||
session.execute.assert_called_once()
|
||||
scoped_session.execute.assert_not_called()
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -25,17 +24,10 @@ def _model_config_payload():
|
||||
|
||||
def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow):
|
||||
class _Service:
|
||||
app_model = None
|
||||
session = None
|
||||
|
||||
def get_draft_workflow(self, app_model, session=None):
|
||||
self.app_model = app_model
|
||||
self.session = session
|
||||
def get_draft_workflow(self, app_model):
|
||||
return workflow
|
||||
|
||||
service = _Service()
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: service)
|
||||
return service
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: _Service())
|
||||
|
||||
|
||||
def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -76,8 +68,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
session = MagicMock()
|
||||
session.get.return_value = None
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -89,11 +80,10 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "app app-1 not found"
|
||||
session.get.assert_called_once_with(generator_module.App, "app-1")
|
||||
|
||||
|
||||
def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -101,7 +91,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
_install_workflow_service(monkeypatch, workflow=None)
|
||||
|
||||
with app.test_request_context(
|
||||
@ -114,7 +104,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "workflow app-1 not found"
|
||||
@ -125,7 +115,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
@ -140,7 +130,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "node node-1 not found"
|
||||
@ -151,7 +141,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
@ -160,7 +150,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
]
|
||||
}
|
||||
)
|
||||
workflow_service = _install_workflow_service(monkeypatch, workflow=workflow)
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", lambda **_kwargs: {"code": "x"})
|
||||
|
||||
with app.test_request_context(
|
||||
@ -173,17 +163,14 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(session, "t1")
|
||||
response = method("t1")
|
||||
|
||||
assert response == {"code": "x"}
|
||||
assert workflow_service.app_model is app_model
|
||||
assert workflow_service.session is session
|
||||
|
||||
|
||||
def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
@ -202,7 +189,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method(session, "t1")
|
||||
response = method("t1")
|
||||
|
||||
assert response == {"instruction": "ok"}
|
||||
|
||||
@ -210,7 +197,6 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -223,7 +209,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method(session, "t1")
|
||||
response, status = method("t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "incompatible parameters"
|
||||
|
||||
@ -346,19 +346,6 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_uses_provided_session(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow can reuse an injected SQLAlchemy session."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
session.scalar.assert_called_once()
|
||||
mock_db_session.session.scalar.assert_not_called()
|
||||
|
||||
def test_get_draft_workflow_returns_none(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
@ -383,21 +370,6 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_with_workflow_id_reuses_provided_session(self, workflow_service):
|
||||
"""Test get_draft_workflow passes an injected session to published workflow lookup."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
workflow_id = "workflow-123"
|
||||
session = MagicMock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
|
||||
|
||||
with patch.object(
|
||||
workflow_service, "get_published_workflow_by_id", return_value=mock_workflow
|
||||
) as mock_get_published:
|
||||
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
mock_get_published.assert_called_once_with(app, workflow_id, session=session)
|
||||
|
||||
# ==================== Get Published Workflow Tests ====================
|
||||
# These tests verify retrieval of published workflows (versioned snapshots)
|
||||
|
||||
|
||||
@ -1,131 +1,202 @@
|
||||
import type { Key, Store } from '../store/store.js'
|
||||
import type { AccountContext } from './hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { HostsBundleSchema, loadHosts, saveHosts } from './hosts.js'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts.js'
|
||||
|
||||
describe('HostsBundleSchema', () => {
|
||||
it('parses a minimal logged-out bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({})
|
||||
expect(parsed.current_host).toBe('')
|
||||
expect(parsed.token_storage).toBe('file')
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
})
|
||||
|
||||
it('parses a logged-in keychain bundle', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(parsed.token_storage).toBe('keychain')
|
||||
expect(parsed.tokens).toBeUndefined()
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
})
|
||||
|
||||
it('parses a logged-in file bundle with bearer', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_xxx' },
|
||||
})
|
||||
expect(parsed.tokens?.bearer).toBe('dfoa_xxx')
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => HostsBundleSchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('keeps available_workspaces when provided', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
available_workspaces: [
|
||||
{ id: 'a', name: 'A', role: 'owner' },
|
||||
{ id: 'b', name: 'B', role: 'member' },
|
||||
],
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
})
|
||||
expect(parsed.available_workspaces).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('drops unknown top-level fields on parse', () => {
|
||||
const parsed = HostsBundleSchema.parse({
|
||||
current_host: 'cloud.dify.ai',
|
||||
future_field: 42,
|
||||
token_storage: 'file',
|
||||
})
|
||||
expect(parsed.current_host).toBe('cloud.dify.ai')
|
||||
expect((parsed as Record<string, unknown>).future_field).toBeUndefined()
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadHosts/saveHosts', () => {
|
||||
let dir: string
|
||||
let prevConfigDir: string | undefined
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-hosts-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-reg-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns undefined when nothing was saved', () => {
|
||||
expect(loadHosts()).toBeUndefined()
|
||||
it('returns an empty registry when nothing saved', () => {
|
||||
const reg = Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('round-trips a fully-populated bundle', () => {
|
||||
saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
scheme: 'https',
|
||||
account: { id: 'acct-1', email: 'a@b.c', name: 'A' },
|
||||
workspace: { id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'My Space', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok_xyz',
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
it('round-trips a populated registry', () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
const loaded = Registry.load()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.scheme).toBe('https')
|
||||
expect(loaded?.account?.email).toBe('a@b.c')
|
||||
expect(loaded?.workspace?.id).toBe('ws-1')
|
||||
expect(loaded?.available_workspaces).toHaveLength(2)
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
expect(loaded?.token_id).toBe('tok_xyz')
|
||||
})
|
||||
|
||||
it('round-trips a file-mode bundle with bearer token', () => {
|
||||
saveHosts({
|
||||
current_host: 'self.example.com',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(loaded?.token_storage).toBe('file')
|
||||
})
|
||||
|
||||
it('overwrites previous bundle on save', () => {
|
||||
saveHosts({ current_host: 'old.example.com', token_storage: 'file' })
|
||||
saveHosts({ current_host: 'new.example.com', token_storage: 'keychain' })
|
||||
const loaded = loadHosts()
|
||||
expect(loaded?.current_host).toBe('new.example.com')
|
||||
expect(loaded?.token_storage).toBe('keychain')
|
||||
})
|
||||
|
||||
it('rejects invalid input at save time', () => {
|
||||
expect(() => saveHosts({
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'cloud',
|
||||
} as never)).toThrow()
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-forget-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
reg.forget(active, store)
|
||||
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import type { Store } from '../store/store.js'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
import { getHostStore, tokenKey } from '../store/manager.js'
|
||||
|
||||
const StorageModeSchema = z.enum(['keychain', 'file'])
|
||||
@ -25,42 +27,152 @@ export const ExternalSubjectSchema = z.object({
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const TokensSchema = z.object({
|
||||
bearer: z.string(),
|
||||
})
|
||||
export type Tokens = z.infer<typeof TokensSchema>
|
||||
|
||||
export const HostsBundleSchema = z.object({
|
||||
current_host: z.string().default(''),
|
||||
scheme: z.string().optional(),
|
||||
account: AccountSchema.optional(),
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
available_workspaces: z.array(WorkspaceSchema).optional(),
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
tokens: TokensSchema.optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type HostsBundle = z.infer<typeof HostsBundleSchema>
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
|
||||
export function loadHosts(): HostsBundle | undefined {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return undefined
|
||||
return HostsBundleSchema.parse(raw)
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
}
|
||||
|
||||
export function saveHosts(bundle: HostsBundle): void {
|
||||
const validated = HostsBundleSchema.parse(bundle)
|
||||
getHostStore().setTyped(validated)
|
||||
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
export function clearLocal(bundle: HostsBundle, store: Store): void {
|
||||
const accountId = bundle.account?.id ?? bundle.external_subject?.email ?? 'default'
|
||||
try {
|
||||
store.unset(tokenKey(bundle.current_host, accountId))
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static load(): Registry {
|
||||
const raw = getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null)
|
||||
return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] { return this.data.hosts }
|
||||
get current_host(): string | undefined { return this.data.current_host }
|
||||
get token_storage(): StorageMode { return this.data.token_storage }
|
||||
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '')
|
||||
return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return undefined
|
||||
const email = entry.current_account
|
||||
if (email === undefined || email === '')
|
||||
return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined)
|
||||
return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined)
|
||||
throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined)
|
||||
return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive)
|
||||
entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host)
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined)
|
||||
return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined)
|
||||
entry.scheme = scheme
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
forget(active: ActiveContext, store: Store): void {
|
||||
try {
|
||||
store.unset(tokenKey(active.host, active.email))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
this.remove(active.host, active.email)
|
||||
this.save()
|
||||
}
|
||||
|
||||
save(): void {
|
||||
getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
getHostStore().rm()
|
||||
}
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../cache/app-info.js'
|
||||
import type { Command } from '../../framework/command.js'
|
||||
import type { Store } from '../../store/store.js'
|
||||
import type { IOStreams } from '../../sys/io/streams'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js'
|
||||
import { loadHosts } from '../../auth/hosts.js'
|
||||
import { notLoggedInError, Registry } from '../../auth/hosts.js'
|
||||
import { loadAppInfoCache } from '../../cache/app-info.js'
|
||||
import { loadNudgeStore } from '../../cache/nudge-store.js'
|
||||
import { getEnv } from '../../env/registry.js'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { formatErrorForCli } from '../../errors/format.js'
|
||||
import { createClient } from '../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../store/manager.js'
|
||||
import { realStreams } from '../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../util/host.js'
|
||||
import { versionInfo } from '../../version/info.js'
|
||||
@ -19,7 +19,9 @@ import { maybeNudgeCompat } from '../../version/nudge.js'
|
||||
import { resolveRetryAttempts } from './global-flags.js'
|
||||
|
||||
export type AuthedContext = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -37,28 +39,30 @@ export async function buildAuthedContext(
|
||||
opts: AuthedContextOptions,
|
||||
): Promise<AuthedContext> {
|
||||
const io = realStreams(opts.format ?? '')
|
||||
const bundle = loadHosts()
|
||||
if (bundle === undefined || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
const err = new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({
|
||||
flag: opts.retryFlag,
|
||||
env: getEnv,
|
||||
})
|
||||
const http = createClient({ host, bearer: bundle.tokens.bearer, retryAttempts })
|
||||
const { store } = getTokenStore()
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
if (bearer === '')
|
||||
fail(cmd, opts, io)
|
||||
|
||||
const host = hostWithScheme(active.host, active.scheme)
|
||||
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
|
||||
const http = createClient({ host, bearer, retryAttempts })
|
||||
|
||||
const cache = opts.withCache === true ? await loadAppInfoCache() : undefined
|
||||
|
||||
await runCompatNudge({ host, io })
|
||||
|
||||
return { bundle, http, host, io, cache }
|
||||
return { reg, active, store, http, host, io, cache }
|
||||
}
|
||||
|
||||
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
|
||||
const err = notLoggedInError()
|
||||
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
|
||||
}
|
||||
|
||||
// Best-effort nudge: never throws, never blocks. Lives here so every authed
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../../auth/hosts.js'
|
||||
import { Registry } from '../../../../auth/hosts.js'
|
||||
import { createClient } from '../../../../http/client.js'
|
||||
import { ENV_CONFIG_DIR, resolveConfigDir } from '../../../../store/dir.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../../store/dir.js'
|
||||
import { tokenKey } from '../../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../../sys/io/streams'
|
||||
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
|
||||
@ -30,20 +30,21 @@ class MemStore implements Store {
|
||||
}
|
||||
}
|
||||
|
||||
function bundleFor(host: string, tokenId = 'tok-1'): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: tokenId,
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(host, email, {
|
||||
account: { id: 'acct-1', email, name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
token_id: tokenId,
|
||||
})
|
||||
reg.setHost(host)
|
||||
reg.setAccount(email)
|
||||
const active = reg.resolveActive()!
|
||||
return { reg, active }
|
||||
}
|
||||
|
||||
describe('runDevicesList', () => {
|
||||
@ -58,7 +59,7 @@ describe('runDevicesList', () => {
|
||||
it('table: marks current with *', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url, 'tok-1'), http })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('DEVICE')
|
||||
expect(out).toContain('difyctl on laptop')
|
||||
@ -71,20 +72,12 @@ describe('runDevicesList', () => {
|
||||
it('json: emits PaginationEnvelope unchanged', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await runDevicesList({ io, bundle: bundleFor(mock.url), http, json: true })
|
||||
await runDevicesList({ io, tokenId: 'tok-1', http, json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.page).toBe(1)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
expect((parsed.data as unknown[]).length).toBe(3)
|
||||
})
|
||||
|
||||
it('not-logged-in: throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesList({ io, bundle: undefined, http }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runDevicesRevoke', () => {
|
||||
@ -109,12 +102,12 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact device_label: revokes one + leaves local creds', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl on desktop', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
expect(store.entries.size).toBe(1)
|
||||
})
|
||||
@ -122,30 +115,30 @@ describe('runDevicesRevoke', () => {
|
||||
it('exact id: revokes one', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-2', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-2', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: unique match revokes', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'web', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'web', all: false })
|
||||
expect(io.outBuf()).toContain('Revoked 1 session(s)')
|
||||
})
|
||||
|
||||
it('substring: ambiguous throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'difyctl', all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
|
||||
.rejects
|
||||
.toThrow(/matches multiple/)
|
||||
})
|
||||
@ -153,10 +146,10 @@ describe('runDevicesRevoke', () => {
|
||||
it('no match throws', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await expect(runDevicesRevoke({ io, bundle: b, http, store, target: 'nonexistent', all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
|
||||
.rejects
|
||||
.toThrow(/no session matches/)
|
||||
})
|
||||
@ -164,31 +157,33 @@ describe('runDevicesRevoke', () => {
|
||||
it('--all: revokes everything except current', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, all: true })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, all: true })
|
||||
expect(io.outBuf()).toContain('Revoked 2 session(s)')
|
||||
})
|
||||
|
||||
it('revoking current id clears local creds', async () => {
|
||||
it('revoking current session clears token and removes context from registry', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const b = bundleFor(mock.url, 'tok-1')
|
||||
store.set(tokenKey(b.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(b)
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
store.set(tokenKey(mock.url, 'tester@dify.ai'), 'dfoa_test')
|
||||
reg.save()
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runDevicesRevoke({ io, bundle: b, http, store, target: 'tok-1', all: false })
|
||||
await runDevicesRevoke({ io, reg, active, store, http, target: 'tok-1', all: false })
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(resolveConfigDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
const saved = Registry.load()
|
||||
expect(saved?.hosts[mock.url]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('no target + no --all: throws UsageMissingArg', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
await expect(runDevicesRevoke({ io, bundle: bundleFor(mock.url), http, store, all: false }))
|
||||
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
|
||||
.rejects
|
||||
.toThrow(/specify a device label/)
|
||||
})
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import type { SessionRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../../auth/hosts.js'
|
||||
import type { ActiveContext, Registry } from '../../../../auth/hosts.js'
|
||||
import type { Store } from '../../../../store/store.js'
|
||||
import type { IOStreams } from '../../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../../errors/codes.js'
|
||||
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
|
||||
import { getTokenStore } from '../../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../../sys/io/color.js'
|
||||
import { runWithSpinner } from '../../../../sys/io/spinner.js'
|
||||
|
||||
export type DevicesListOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly tokenId: string
|
||||
readonly http: KyInstance
|
||||
readonly json?: boolean
|
||||
readonly page?: number
|
||||
@ -23,7 +21,6 @@ export type DevicesListOptions = {
|
||||
}
|
||||
|
||||
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
const b = requireLogin(opts.bundle)
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const env = opts.envLookup ?? ((k: string) => process.env[k])
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -38,7 +35,7 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
|
||||
opts.io.out.write(renderTable(envelope.data, opts.tokenId))
|
||||
}
|
||||
|
||||
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
|
||||
@ -72,10 +69,10 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
|
||||
|
||||
export type DevicesRevokeOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly store: Store
|
||||
readonly http: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
readonly target?: string
|
||||
readonly all: boolean
|
||||
readonly yes?: boolean
|
||||
@ -83,7 +80,6 @@ export type DevicesRevokeOptions = {
|
||||
|
||||
export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const b = requireLogin(opts.bundle)
|
||||
if (!opts.all && (opts.target === undefined || opts.target === '')) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
@ -94,7 +90,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
const rows = await listAllSessions(sessions)
|
||||
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
|
||||
const { ids, selfHit } = pickTargets(rows, opts, opts.active.ctx.token_id ?? '')
|
||||
if (ids.length === 0) {
|
||||
opts.io.out.write('no sessions to revoke\n')
|
||||
return
|
||||
@ -103,25 +99,12 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
|
||||
for (const id of ids)
|
||||
await sessions.revoke(id)
|
||||
|
||||
if (selfHit) {
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(b, tokens)
|
||||
}
|
||||
if (selfHit)
|
||||
opts.reg.forget(opts.active, opts.store)
|
||||
|
||||
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
|
||||
}
|
||||
|
||||
function requireLogin(b: HostsBundle | undefined): HostsBundle {
|
||||
if (b === undefined || b.current_host === '' || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
export type PickResult = {
|
||||
ids: readonly string[]
|
||||
selfHit: boolean
|
||||
|
||||
@ -25,7 +25,7 @@ export default class DevicesList extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
await runDevicesList({
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
tokenId: ctx.active.ctx.token_id ?? '',
|
||||
http: ctx.http,
|
||||
json: flags.json,
|
||||
page: flags.page,
|
||||
|
||||
@ -26,7 +26,9 @@ export default class DevicesRevoke extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runDevicesRevoke({
|
||||
io: ctx.io,
|
||||
bundle: ctx.bundle,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
store: ctx.store,
|
||||
http: ctx.http,
|
||||
target: args.target,
|
||||
all: flags.all,
|
||||
|
||||
@ -59,7 +59,7 @@ describe('runLogin', () => {
|
||||
it('happy: stores bearer + writes hosts.yml + greets account user', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
const reg = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -70,16 +70,17 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
expect(bundle.tokens?.bearer).toBe('dfoa_test')
|
||||
expect(bundle.account?.email).toBe('tester@dify.ai')
|
||||
expect(bundle.workspace?.id).toBe('ws-1')
|
||||
expect(bundle.available_workspaces).toHaveLength(2)
|
||||
const stored = store.get(tokenKey(bundle.current_host, 'acct-1'))
|
||||
expect(stored).toBe('dfoa_test')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.account.email).toBe('tester@dify.ai')
|
||||
expect(active?.ctx.workspace?.id).toBe('ws-1')
|
||||
expect(active?.ctx.available_workspaces).toHaveLength(2)
|
||||
expect(store.get(tokenKey(active!.host, 'tester@dify.ai'))).toBe('dfoa_test')
|
||||
|
||||
const hostsRaw = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsRaw).toContain('current_host:')
|
||||
expect(hostsRaw).toContain('tester@dify.ai')
|
||||
expect(hostsRaw).not.toContain('dfoa_test')
|
||||
expect(hostsRaw).not.toContain('bearer')
|
||||
|
||||
expect(io.outBuf()).toContain('Logged in to')
|
||||
expect(io.outBuf()).toContain('tester@dify.ai')
|
||||
@ -91,7 +92,7 @@ describe('runLogin', () => {
|
||||
mock.setScenario('sso')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = await runLogin({
|
||||
const reg = await runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
@ -102,12 +103,11 @@ describe('runLogin', () => {
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})
|
||||
expect(bundle.tokens?.bearer).toBe('dfoe_test')
|
||||
expect(bundle.account).toBeUndefined()
|
||||
expect(bundle.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(bundle.external_subject?.issuer).toBe('https://issuer.example')
|
||||
const stored = await store.get(bundle.current_host, 'sso@dify.ai')
|
||||
expect(stored).toBe('dfoe_test')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.ctx.external_subject?.email).toBe('sso@dify.ai')
|
||||
expect(active?.ctx.external_subject?.issuer).toBe('https://issuer.example')
|
||||
expect(active?.ctx.account.email).toBe('')
|
||||
expect(store.get(tokenKey(active!.host, 'sso@dify.ai'))).toBe('dfoe_test')
|
||||
expect(io.outBuf()).toContain('external SSO')
|
||||
expect(io.outBuf()).toContain('sso@dify.ai')
|
||||
})
|
||||
@ -148,6 +148,24 @@ describe('runLogin', () => {
|
||||
})).rejects.toThrow(/expired/)
|
||||
})
|
||||
|
||||
it('rejects login when the account has no email', async () => {
|
||||
mock.setScenario('no-email')
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogin({
|
||||
io,
|
||||
host: mock.url,
|
||||
noBrowser: true,
|
||||
insecure: true,
|
||||
deviceLabel: 'difyctl on test',
|
||||
api: new DeviceFlowApi(createClient({ host: mock.url })),
|
||||
store: { store, mode: 'file' },
|
||||
clock: noopClock,
|
||||
browserOpener: noopBrowser,
|
||||
})).rejects.toThrow(/no email/i)
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects http:// host without --insecure', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { CodeResponse, PollSuccess } from '../../../api/oauth-device.js'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { AccountContext, Workspace } from '../../../auth/hosts.js'
|
||||
import type { StorageMode, Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { BrowserEnv, BrowserOpener } from '../../../util/browser.js'
|
||||
@ -7,10 +7,13 @@ import type { Clock } from './device-flow.js'
|
||||
import * as os from 'node:os'
|
||||
import * as readline from 'node:readline'
|
||||
import { DeviceFlowApi } from '../../../api/oauth-device.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { startSpinner } from '../../../sys/io/spinner.js'
|
||||
import { decideOpen, OpenDecision, openUrl, realEnv } from '../../../util/browser.js'
|
||||
import { bareHost, DEFAULT_HOST, resolveHost, validateVerificationURI } from '../../../util/host.js'
|
||||
import { awaitAuthorization, realClock } from './device-flow.js'
|
||||
@ -28,7 +31,7 @@ export type LoginOptions = {
|
||||
readonly clock?: Clock
|
||||
}
|
||||
|
||||
export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
export async function runLogin(opts: LoginOptions): Promise<Registry> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const insecure = opts.insecure ?? false
|
||||
|
||||
@ -56,22 +59,44 @@ export async function runLogin(opts: LoginOptions): Promise<HostsBundle> {
|
||||
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
|
||||
}
|
||||
|
||||
const success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
const spinner = startSpinner({ io: opts.io, label: 'Waiting for authorization', style: 'dify' })
|
||||
let success: PollSuccess
|
||||
try {
|
||||
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
|
||||
}
|
||||
finally {
|
||||
spinner.stop()
|
||||
}
|
||||
|
||||
const storeBundle = opts.store ?? getTokenStore()
|
||||
const bundle = bundleFromSuccess(host, success, storeBundle.mode)
|
||||
const display = bareHost(host)
|
||||
const email = accountEmail(success)
|
||||
const ctx = contextFromSuccess(success)
|
||||
|
||||
storeBundle.store.set(tokenKey(bundle.current_host, accountKey(bundle)), success.token)
|
||||
saveHosts(bundle)
|
||||
storeBundle.store.set(tokenKey(display, email), success.token)
|
||||
|
||||
const reg = Registry.load()
|
||||
reg.token_storage = storeBundle.mode
|
||||
reg.activate(display, email, ctx)
|
||||
applyScheme(reg, display, host)
|
||||
reg.save()
|
||||
|
||||
renderLoggedIn(opts.io.out, cs, host, success)
|
||||
return bundle
|
||||
return reg
|
||||
}
|
||||
|
||||
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
|
||||
let raw = opts.host?.trim() ?? ''
|
||||
if (raw === '')
|
||||
if (raw === '') {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: '--host is required (no TTY)',
|
||||
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
|
||||
})
|
||||
}
|
||||
raw = await promptHost(opts.io)
|
||||
}
|
||||
return resolveHost({ raw, insecure })
|
||||
}
|
||||
|
||||
@ -122,50 +147,43 @@ function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role:
|
||||
return s.workspaces?.find(w => w.id === s.default_workspace_id)
|
||||
}
|
||||
|
||||
function bundleFromSuccess(host: string, s: PollSuccess, mode: StorageMode): HostsBundle {
|
||||
const display = bareHost(host)
|
||||
let scheme: string | undefined
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
scheme = u.protocol.replace(':', '')
|
||||
function accountEmail(s: PollSuccess): string {
|
||||
const email = (s.account?.email ?? '') !== '' ? s.account!.email : (s.subject_email ?? '')
|
||||
if (email === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'account has no email; cannot store credential',
|
||||
hint: 'this Dify instance returned no email for the signed-in subject',
|
||||
})
|
||||
}
|
||||
catch { /* keep undefined */ }
|
||||
return email
|
||||
}
|
||||
|
||||
const bundle: HostsBundle = {
|
||||
current_host: display,
|
||||
scheme,
|
||||
token_storage: mode,
|
||||
function contextFromSuccess(s: PollSuccess): AccountContext {
|
||||
const ctx: AccountContext = {
|
||||
account: s.account
|
||||
? { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
: { id: '', email: '', name: '' },
|
||||
token_id: s.token_id,
|
||||
tokens: { bearer: s.token },
|
||||
}
|
||||
if (s.account) {
|
||||
bundle.account = { id: s.account.id, email: s.account.email, name: s.account.name }
|
||||
}
|
||||
if (s.subject_email !== undefined && s.subject_email !== ''
|
||||
&& (!s.account || s.account.id === '')) {
|
||||
bundle.external_subject = {
|
||||
email: s.subject_email,
|
||||
issuer: s.subject_issuer ?? '',
|
||||
}
|
||||
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
|
||||
}
|
||||
const def = findDefaultWorkspace(s)
|
||||
if (def !== undefined)
|
||||
bundle.workspace = def
|
||||
ctx.workspace = def
|
||||
if (s.workspaces !== undefined && s.workspaces.length > 0) {
|
||||
bundle.available_workspaces = s.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
}))
|
||||
ctx.available_workspaces = s.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role }))
|
||||
}
|
||||
return bundle
|
||||
return ctx
|
||||
}
|
||||
|
||||
function accountKey(b: HostsBundle): string {
|
||||
if (b.account?.id !== undefined && b.account.id !== '')
|
||||
return b.account.id
|
||||
if (b.external_subject?.email !== undefined && b.external_subject.email !== '')
|
||||
return b.external_subject.email
|
||||
return 'default'
|
||||
function applyScheme(reg: Registry, display: string, host: string): void {
|
||||
try {
|
||||
const u = new URL(host)
|
||||
if (u.protocol !== 'https:')
|
||||
reg.setScheme(display, u.protocol.replace(':', ''))
|
||||
}
|
||||
catch { /* keep scheme unset */ }
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { hostWithScheme } from '../../../util/host.js'
|
||||
@ -16,21 +17,21 @@ export default class Logout extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
this.parse(Logout, argv)
|
||||
const bundle = loadHosts()
|
||||
const io = realStreams()
|
||||
const reg = Registry.load()
|
||||
const active = reg.resolveActive()
|
||||
|
||||
let http: KyInstance | undefined
|
||||
if (bundle !== undefined && bundle.current_host !== '' && bundle.tokens?.bearer !== undefined && bundle.tokens.bearer !== '') {
|
||||
http = createClient({
|
||||
host: hostWithScheme(bundle.current_host, bundle.scheme),
|
||||
bearer: bundle.tokens.bearer,
|
||||
retryAttempts: 0,
|
||||
})
|
||||
if (active !== undefined) {
|
||||
const bearer = getTokenStore().store.get(tokenKey(active.host, active.email))
|
||||
if (bearer !== '') {
|
||||
http = createClient({ host: hostWithScheme(active.host, active.scheme), bearer, retryAttempts: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const io = realStreams()
|
||||
await runWithSpinner(
|
||||
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
|
||||
() => runLogout({ io, bundle, http }),
|
||||
() => runLogout({ io, reg, http }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,145 +1,64 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { createClient } from '../../../http/client.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { tokenKey } from '../../../store/manager.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runLogout } from './logout.js'
|
||||
|
||||
class MemStore implements Store {
|
||||
readonly entries = new Map<string, unknown>()
|
||||
get<T>(key: Key<T>): T {
|
||||
return (this.entries.get(key.key) as T | undefined) ?? key.default
|
||||
}
|
||||
|
||||
set<T>(key: Key<T>, value: T): void {
|
||||
this.entries.set(key.key, value)
|
||||
}
|
||||
|
||||
unset<T>(key: Key<T>): void {
|
||||
this.entries.delete(key.key)
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureBundle(host: string): HostsBundle {
|
||||
return {
|
||||
current_host: host,
|
||||
scheme: 'http',
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
get<T>(key: Key<T>): T { return (this.entries.get(key.key) as T | undefined) ?? key.default }
|
||||
set<T>(key: Key<T>, value: T): void { this.entries.set(key.key, value) }
|
||||
unset<T>(key: Key<T>): void { this.entries.delete(key.key) }
|
||||
}
|
||||
|
||||
describe('runLogout', () => {
|
||||
let mock: DifyMock
|
||||
let configDir: string
|
||||
let prevConfigDir: string | undefined
|
||||
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
configDir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prevConfigDir = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-logout-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevConfigDir === undefined)
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else
|
||||
process.env[ENV_CONFIG_DIR] = prevConfigDir
|
||||
await mock.stop()
|
||||
await rm(configDir, { recursive: true, force: true })
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('happy: revokes server side, clears local store + hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
function seed(store: MemStore) {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
store.set({ key: 'tokens.h1.a@x', default: '' }, 'dfoa_a')
|
||||
store.set({ key: 'tokens.h1.b@x', default: '' }, 'dfoa_b')
|
||||
}
|
||||
|
||||
it('removes only the active context, keeps others, unsets pointers, file survives', async () => {
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
await expect(readFile(join(configDir, 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
expect(io.errBuf()).toBe('')
|
||||
seed(store)
|
||||
await runLogout({ io: bufferStreams(), reg: Registry.load(), store })
|
||||
const after = Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
expect(store.get({ key: 'tokens.h1.a@x', default: '' })).toBe('')
|
||||
expect(store.get({ key: 'tokens.h1.b@x', default: '' })).toBe('dfoa_b')
|
||||
const raw = await readFile(join(dir, 'hosts.yml'), 'utf8')
|
||||
expect(raw).toContain('b@x')
|
||||
})
|
||||
|
||||
it('not-logged-in: throws BaseError', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
await expect(runLogout({ io, bundle: undefined, store })).rejects.toThrow(/not logged in/)
|
||||
})
|
||||
|
||||
it('hosts.yml absent: still completes locally + emits success', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('server revoke fails: warns to stderr but still clears local + exits 0', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfoa_test')
|
||||
saveHosts(bundle)
|
||||
mock.setScenario('server-5xx')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(store.entries.size).toBe(0)
|
||||
expect(io.errBuf()).toContain('server revoke failed')
|
||||
expect(io.outBuf()).toContain('Logged out of')
|
||||
})
|
||||
|
||||
it('skips server revoke for non-OAuth bearer (e.g. dfp_)', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
bundle.tokens = { bearer: 'dfp_personal_token' }
|
||||
store.set(tokenKey(bundle.current_host, 'acct-1'), 'dfp_personal_token')
|
||||
saveHosts(bundle)
|
||||
const http = createClient({ host: mock.url, bearer: 'dfp_personal_token' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
expect(io.errBuf()).toBe('')
|
||||
expect(store.entries.size).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves unrelated files in configDir', async () => {
|
||||
const io = bufferStreams()
|
||||
const store = new MemStore()
|
||||
const bundle = fixtureBundle(mock.url)
|
||||
saveHosts(bundle)
|
||||
await writeFile(join(configDir, 'config.yml'), 'foo: bar\n', 'utf8')
|
||||
const http = createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
|
||||
await runLogout({ io, bundle, http, store })
|
||||
|
||||
const cfg = await readFile(join(configDir, 'config.yml'), 'utf8')
|
||||
expect(cfg).toContain('foo: bar')
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runLogout({ io: bufferStreams(), reg: Registry.load(), store: new MemStore() }))
|
||||
.rejects
|
||||
.toThrow(/not logged in/i)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,54 +1,46 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AccountSessionsClient } from '../../../api/account-sessions.js'
|
||||
import { clearLocal } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore } from '../../../store/manager.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
|
||||
export type LogoutOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly http?: KyInstance
|
||||
/** Optional override for tests; production code resolves via `getTokenStore`. */
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
export async function runLogout(opts: LogoutOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
const reg = opts.reg
|
||||
const active = reg.requireActive()
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
const bearer = store.get(tokenKey(active.host, active.email))
|
||||
|
||||
let revokeWarning = ''
|
||||
if (revokeAllowed(bundle.tokens.bearer) && opts.http !== undefined) {
|
||||
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
|
||||
try {
|
||||
const sessions = new AccountSessionsClient(opts.http)
|
||||
await sessions.revokeSelf()
|
||||
await new AccountSessionsClient(opts.http).revokeSelf()
|
||||
}
|
||||
catch (err) {
|
||||
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = opts.store ?? getTokenStore().store
|
||||
clearLocal(bundle, tokens)
|
||||
reg.forget(active, store)
|
||||
|
||||
if (revokeWarning !== '')
|
||||
opts.io.err.write(revokeWarning)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${bundle.current_host}\n`)
|
||||
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
|
||||
}
|
||||
|
||||
const REVOCABLE_PREFIXES = ['dfoa_', 'dfoe_'] as const
|
||||
|
||||
function revokeAllowed(bearer: string): boolean {
|
||||
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -20,7 +20,7 @@ export default class Status extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Status, argv)
|
||||
const bundle = loadHosts()
|
||||
await runStatus({ io: realStreams(), bundle, verbose: flags.verbose, json: flags.json })
|
||||
const reg = Registry.load()
|
||||
await runStatus({ io: realStreams(), reg, verbose: flags.verbose, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,65 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runStatus } from './status.js'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'keychain',
|
||||
token_id: 'tok-1',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
}
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'tester@dify.ai',
|
||||
accounts: {
|
||||
'tester@dify.ai': {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_id: 'tok-1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function ssoBundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
token_id: 'tok-sso-1',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'sso@dify.ai',
|
||||
accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { id: '', email: '', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('runStatus', () => {
|
||||
it('logged-out: prints message + throws NotLoggedIn', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, reg: Registry.empty() })).rejects.toThrow(/not logged in/)
|
||||
expect(io.outBuf()).toContain('Not logged in')
|
||||
})
|
||||
|
||||
it('logged-out json: emits {logged_in: false}', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runStatus({ io, bundle: undefined, json: true })).rejects.toThrow(/not logged in/)
|
||||
await expect(runStatus({ io, reg: Registry.empty(), json: true })).rejects.toThrow(/not logged in/)
|
||||
expect(JSON.parse(io.outBuf())).toEqual({ host: null, logged_in: false })
|
||||
})
|
||||
|
||||
it('account: human compact', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle() })
|
||||
await runStatus({ io, reg: accountReg() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('Logged in to cloud.dify.ai as tester@dify.ai (Test Tester)')
|
||||
expect(out).toContain('Workspace: Default')
|
||||
@ -52,7 +68,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account verbose: shows ids + storage + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), verbose: true })
|
||||
await runStatus({ io, reg: accountReg(), verbose: true })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('cloud.dify.ai')
|
||||
expect(out).toContain('Account:')
|
||||
@ -60,11 +76,12 @@ describe('runStatus', () => {
|
||||
expect(out).toContain('Workspace: Default (ws-1, role: owner)')
|
||||
expect(out).toContain('Available: 2 workspaces')
|
||||
expect(out).toContain('Storage: keychain')
|
||||
expect(out).toContain('Contexts:')
|
||||
})
|
||||
|
||||
it('sso: human compact mentions issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle() })
|
||||
await runStatus({ io, reg: ssoReg() })
|
||||
const out = io.outBuf()
|
||||
expect(out).toContain('sso@dify.ai (via https://issuer.example)')
|
||||
expect(out).toContain('apps:run')
|
||||
@ -72,7 +89,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('account json: matches schema with workspace + workspace count', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: accountBundle(), json: true })
|
||||
await runStatus({ io, reg: accountReg(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.host).toBe('cloud.dify.ai')
|
||||
expect(parsed.logged_in).toBe(true)
|
||||
@ -84,7 +101,7 @@ describe('runStatus', () => {
|
||||
|
||||
it('sso json: subject_type external_sso + email + issuer, no account', async () => {
|
||||
const io = bufferStreams()
|
||||
await runStatus({ io, bundle: ssoBundle(), json: true })
|
||||
await runStatus({ io, reg: ssoReg(), json: true })
|
||||
const parsed = JSON.parse(io.outBuf()) as Record<string, unknown>
|
||||
expect(parsed.subject_type).toBe('external_sso')
|
||||
expect(parsed.subject_email).toBe('sso@dify.ai')
|
||||
|
||||
@ -1,91 +1,94 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { AccountContext, Registry } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type StatusOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly verbose?: boolean
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runStatus(opts: StatusOptions): Promise<void> {
|
||||
const bundle = opts.bundle
|
||||
if (bundle === undefined || bundle.current_host === '' || bundle.tokens?.bearer === undefined || bundle.tokens.bearer === '') {
|
||||
if (opts.json === true) {
|
||||
const reg = opts.reg
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined) {
|
||||
if (opts.json === true)
|
||||
opts.io.out.write(`${JSON.stringify({ host: null, logged_in: false })}\n`)
|
||||
}
|
||||
else {
|
||||
else
|
||||
opts.io.out.write('Not logged in. Run \'difyctl auth login\' to sign in.\n')
|
||||
}
|
||||
throw new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in' })
|
||||
}
|
||||
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${renderJson(bundle)}\n`)
|
||||
opts.io.out.write(`${renderJson(active.host, active.ctx, reg.token_storage)}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(renderHuman(bundle, opts.verbose ?? false))
|
||||
opts.io.out.write(renderHuman(active.host, active.ctx, reg.token_storage, opts.verbose ?? false))
|
||||
if (opts.verbose === true)
|
||||
opts.io.out.write(renderContexts(reg))
|
||||
}
|
||||
|
||||
function renderHuman(b: HostsBundle, verbose: boolean): string {
|
||||
function renderHuman(host: string, ctx: AccountContext, storage: string, verbose: boolean): string {
|
||||
const lines: string[] = []
|
||||
const sub = ctx.external_subject
|
||||
if (!verbose) {
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
if (sub !== undefined) {
|
||||
lines.push(sub.issuer !== ''
|
||||
? `Logged in to ${b.current_host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${b.current_host} as ${sub.email} (via SSO)`)
|
||||
? `Logged in to ${host} as ${sub.email} (via ${sub.issuer})`
|
||||
: `Logged in to ${host} as ${sub.email} (via SSO)`)
|
||||
lines.push(' Scope: apps:run')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(`Logged in to ${b.current_host} as ${acc.email} (${acc.name})`)
|
||||
if (b.workspace?.name !== undefined && b.workspace.name !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name}`)
|
||||
lines.push(`Logged in to ${host} as ${ctx.account.email} (${ctx.account.name})`)
|
||||
if (ctx.workspace?.name !== undefined && ctx.workspace.name !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name}`)
|
||||
lines.push(' Session: Dify account — full access')
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = b.external_subject
|
||||
lines.push(b.current_host)
|
||||
if (sub !== undefined) {
|
||||
lines.push(host)
|
||||
lines.push(sub.issuer !== ''
|
||||
? ` Subject: ${sub.email} (external SSO, issuer: ${sub.issuer})`
|
||||
: ` Subject: ${sub.email} (external SSO)`)
|
||||
lines.push(' Session: External SSO — can run apps, cannot manage workspace resources (scope: apps:run)')
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
lines.push(` Storage: ${storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
lines.push(b.current_host)
|
||||
lines.push(` Account: ${acc.email} (${acc.name}, ${acc.id ?? ''})`)
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '')
|
||||
lines.push(` Workspace: ${b.workspace.name} (${b.workspace.id}, role: ${b.workspace.role})`)
|
||||
lines.push(` Available: ${b.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(host)
|
||||
lines.push(` Account: ${ctx.account.email} (${ctx.account.name}, ${ctx.account.id ?? ''})`)
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
lines.push(` Workspace: ${ctx.workspace.name} (${ctx.workspace.id}, role: ${ctx.workspace.role})`)
|
||||
lines.push(` Available: ${ctx.available_workspaces?.length ?? 0} workspaces`)
|
||||
lines.push(' Session: Dify account — full access (scope: full)')
|
||||
lines.push(` Storage: ${b.token_storage}`)
|
||||
lines.push(` Storage: ${storage}`)
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(b: HostsBundle): string {
|
||||
const out: Record<string, unknown> = {
|
||||
host: b.current_host,
|
||||
logged_in: true,
|
||||
storage: b.token_storage,
|
||||
}
|
||||
if (b.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = b.external_subject.email
|
||||
out.subject_issuer = b.external_subject.issuer
|
||||
}
|
||||
else if (b.account !== undefined) {
|
||||
out.account = { id: b.account.id ?? '', email: b.account.email, name: b.account.name }
|
||||
if (b.workspace?.id !== undefined && b.workspace.id !== '') {
|
||||
out.workspace = { id: b.workspace.id, name: b.workspace.name, role: b.workspace.role }
|
||||
function renderContexts(reg: Registry): string {
|
||||
const lines = ['Contexts:']
|
||||
for (const [host, entry] of Object.entries(reg.hosts)) {
|
||||
for (const email of Object.keys(entry.accounts)) {
|
||||
const isActive = reg.current_host === host && entry.current_account === email
|
||||
lines.push(` ${isActive ? '*' : ' '} ${host} ${email}`)
|
||||
}
|
||||
out.available_workspaces_count = b.available_workspaces?.length ?? 0
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function renderJson(host: string, ctx: AccountContext, storage: string): string {
|
||||
const out: Record<string, unknown> = { host, logged_in: true, storage }
|
||||
if (ctx.external_subject !== undefined) {
|
||||
out.subject_type = 'external_sso'
|
||||
out.subject_email = ctx.external_subject.email
|
||||
out.subject_issuer = ctx.external_subject.issuer
|
||||
}
|
||||
else {
|
||||
out.account = { id: ctx.account.id ?? '', email: ctx.account.email, name: ctx.account.name }
|
||||
if (ctx.workspace?.id !== undefined && ctx.workspace.id !== '')
|
||||
out.workspace = { id: ctx.workspace.id, name: ctx.workspace.name, role: ctx.workspace.role }
|
||||
out.available_workspaces_count = ctx.available_workspaces?.length ?? 0
|
||||
}
|
||||
return JSON.stringify(out, null, 2)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { loadHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
@ -18,7 +18,7 @@ export default class Whoami extends DifyCommand {
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(Whoami, argv)
|
||||
const bundle = loadHosts()
|
||||
await runWhoami({ io: realStreams(), bundle, json: flags.json })
|
||||
const reg = Registry.load()
|
||||
await runWhoami({ io: realStreams(), reg, json: flags.json })
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,68 +1,82 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runWhoami } from './whoami.js'
|
||||
|
||||
function accountBundle(): HostsBundle {
|
||||
return {
|
||||
function accountReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'keychain',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
}
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
|
||||
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
function ssoReg(): Registry {
|
||||
return Registry.from({
|
||||
token_storage: 'file',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
|
||||
'sso@dify.ai': {
|
||||
account: { email: 'sso@dify.ai', name: '' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
},
|
||||
} } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('runWhoami', () => {
|
||||
it('logged-out: throws NotLoggedIn', async () => {
|
||||
it('throws NotLoggedIn when no active context', async () => {
|
||||
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
|
||||
})
|
||||
|
||||
it('prints email + name for an account', async () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runWhoami({ io, bundle: undefined })).rejects.toThrow(/not logged in/)
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toContain('a@b.c')
|
||||
expect(io.outBuf()).toContain('Ann')
|
||||
})
|
||||
|
||||
it('account human: emits "email (name)"', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, bundle: accountBundle() })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai (Test Tester)\n')
|
||||
await runWhoami({ io, reg: accountReg() })
|
||||
expect(io.outBuf()).toBe('a@b.c (Ann)\n')
|
||||
})
|
||||
|
||||
it('account human, no name: emits email only', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = accountBundle()
|
||||
b.account!.name = ''
|
||||
await runWhoami({ io, bundle: b })
|
||||
expect(io.outBuf()).toBe('tester@dify.ai\n')
|
||||
const reg = accountReg()
|
||||
reg.hosts['cloud.dify.ai']!.accounts['a@b.c']!.account.name = ''
|
||||
await runWhoami({ io, reg })
|
||||
expect(io.outBuf()).toBe('a@b.c\n')
|
||||
})
|
||||
|
||||
it('emits JSON when --json', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toMatchObject({ email: 'a@b.c', id: 'acct-1' })
|
||||
})
|
||||
|
||||
it('account json: emits {id, email, name}', async () => {
|
||||
const io = bufferStreams()
|
||||
await runWhoami({ io, bundle: accountBundle(), json: true })
|
||||
await runWhoami({ io, reg: accountReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
id: 'acct-1',
|
||||
email: 'tester@dify.ai',
|
||||
name: 'Test Tester',
|
||||
email: 'a@b.c',
|
||||
name: 'Ann',
|
||||
})
|
||||
})
|
||||
|
||||
it('sso human: emits email + issuer', async () => {
|
||||
const io = bufferStreams()
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b })
|
||||
await runWhoami({ io, reg: ssoReg() })
|
||||
expect(io.outBuf()).toBe('sso@dify.ai (external SSO, issuer: https://issuer.example)\n')
|
||||
})
|
||||
|
||||
it('sso json: emits {subject_type, email, issuer}', async () => {
|
||||
const io = bufferStreams()
|
||||
const b: HostsBundle = {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoe_test' },
|
||||
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
|
||||
}
|
||||
await runWhoami({ io, bundle: b, json: true })
|
||||
await runWhoami({ io, reg: ssoReg(), json: true })
|
||||
expect(JSON.parse(io.outBuf())).toEqual({
|
||||
subject_type: 'external_sso',
|
||||
email: 'sso@dify.ai',
|
||||
|
||||
@ -1,46 +1,31 @@
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { Registry } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
|
||||
export type WhoamiOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly bundle: HostsBundle | undefined
|
||||
readonly reg: Registry
|
||||
readonly json?: boolean
|
||||
}
|
||||
|
||||
export async function runWhoami(opts: WhoamiOptions): Promise<void> {
|
||||
const b = opts.bundle
|
||||
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: 'not logged in',
|
||||
hint: 'run \'difyctl auth login\'',
|
||||
})
|
||||
}
|
||||
const active = opts.reg.requireActive()
|
||||
|
||||
if (b.external_subject !== undefined) {
|
||||
const sub = active.ctx.external_subject
|
||||
if (sub !== undefined) {
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({
|
||||
subject_type: 'external_sso',
|
||||
email: b.external_subject.email,
|
||||
issuer: b.external_subject.issuer,
|
||||
})}\n`)
|
||||
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
|
||||
return
|
||||
}
|
||||
const sub = b.external_subject
|
||||
opts.io.out.write(sub.issuer !== ''
|
||||
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
|
||||
: `${sub.email} (external SSO)\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const acc = b.account ?? { id: '', email: '', name: '' }
|
||||
const acc = active.ctx.account
|
||||
if (opts.json === true) {
|
||||
opts.io.out.write(`${JSON.stringify({ id: acc.id ?? '', email: acc.email, name: acc.name })}\n`)
|
||||
return
|
||||
}
|
||||
opts.io.out.write(acc.name !== ''
|
||||
? `${acc.email} (${acc.name})\n`
|
||||
: `${acc.email}\n`)
|
||||
opts.io.out.write(acc.name !== '' ? `${acc.email} (${acc.name})\n` : `${acc.email}\n`)
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ export default class CreateMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runCreateMember(
|
||||
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runCreateMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'inviter@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +36,7 @@ describe('runCreateMember', () => {
|
||||
const result = await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -60,7 +61,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: 'new@example.com', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -76,7 +77,7 @@ describe('runCreateMember', () => {
|
||||
runCreateMember(
|
||||
{ email: '', role: 'normal' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -91,7 +92,7 @@ describe('runCreateMember', () => {
|
||||
await runCreateMember(
|
||||
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type CreateMemberOptions = {
|
||||
}
|
||||
|
||||
export type CreateMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runCreateMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
const response = await runWithSpinner(
|
||||
|
||||
@ -33,7 +33,7 @@ export default class DeleteMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runDeleteMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +28,7 @@ describe('runDeleteMember', () => {
|
||||
const result = await runDeleteMember(
|
||||
{ memberId: 'acct-2' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -45,7 +46,7 @@ describe('runDeleteMember', () => {
|
||||
await runDeleteMember(
|
||||
{ memberId: 'acct-2', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -60,7 +61,7 @@ describe('runDeleteMember', () => {
|
||||
runDeleteMember(
|
||||
{ memberId: '' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import * as readline from 'node:readline'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
@ -19,7 +19,7 @@ export type DeleteMemberOptions = {
|
||||
}
|
||||
|
||||
export type DeleteMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -51,7 +51,7 @@ export async function runDeleteMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
if (!opts.yes && io.isErrTTY) {
|
||||
|
||||
@ -32,7 +32,7 @@ export default class DescribeApp extends DifyCommand {
|
||||
format,
|
||||
data: await runDescribeApp(
|
||||
{ appId: args.id, workspace: flags.workspace, format, refresh: flags.refresh },
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -12,17 +12,18 @@ import { ENV_CACHE_DIR } from '../../../store/dir.js'
|
||||
import { CACHE_APP_INFO, getCache } from '../../../store/manager.js'
|
||||
import { runDescribeApp } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,7 +50,7 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const data = await runDescribeApp(
|
||||
opts,
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
|
||||
}
|
||||
@ -92,13 +93,13 @@ describe('runDescribeApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const before = cache.get(mock.url, 'app-1')
|
||||
expect(before).toBeDefined()
|
||||
await runDescribeApp(
|
||||
{ appId: 'app-1', refresh: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, cache },
|
||||
)
|
||||
const after = cache.get(mock.url, 'app-1')
|
||||
expect(after?.fetchedAt).not.toBe(before?.fetchedAt ?? '')
|
||||
@ -112,7 +113,7 @@ describe('runDescribeApp', () => {
|
||||
await expect(runDescribeApp(
|
||||
{ appId: 'nope' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -19,7 +19,7 @@ export type DescribeAppOptions = {
|
||||
}
|
||||
|
||||
export type DescribeAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io?: IOStreams
|
||||
@ -29,7 +29,7 @@ export type DescribeAppDeps = {
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
|
||||
@ -59,7 +59,7 @@ export default class GetApp extends DifyCommand {
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
format,
|
||||
data: result.data,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,17 +7,18 @@ import { createClient } from '../../../http/client.js'
|
||||
import { AppListOutput } from './handlers.js'
|
||||
import { runGetApp } from './run.js'
|
||||
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetApp', () => {
|
||||
@ -36,7 +37,7 @@ describe('runGetApp', () => {
|
||||
}
|
||||
|
||||
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
|
||||
const result = await runGetApp(opts, { bundle: baseBundle, http: http() })
|
||||
const result = await runGetApp(opts, { active: baseActive, http: http() })
|
||||
return stringifyOutput(table({
|
||||
format: opts.format ?? '',
|
||||
data: result.data,
|
||||
@ -134,7 +135,11 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
it('throws NotLoggedIn-equivalent when no workspace can be resolved', async () => {
|
||||
const minimal: HostsBundle = { current_host: 'h', token_storage: 'file' }
|
||||
await expect(runGetApp({}, { bundle: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
const minimal: ActiveContext = {
|
||||
host: 'h',
|
||||
email: 'x@x.com',
|
||||
ctx: { account: { email: 'x@x.com', name: 'X' } },
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppsClient } from '../../../api/apps.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
@ -24,7 +24,7 @@ export type GetAppOptions = {
|
||||
}
|
||||
|
||||
export type GetAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -57,12 +57,12 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
return runAllWorkspaces(apps, ws, opts, page, pageSize)
|
||||
}
|
||||
if (opts.appId !== undefined && opts.appId !== '') {
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsName = workspaceNameForId(deps.bundle, wsId)
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, wsId)
|
||||
const desc = await apps.describe(opts.appId, wsId, ['info'])
|
||||
return describeToEnvelope(desc, wsId, wsName)
|
||||
}
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
@ -111,12 +111,13 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceNameForId(b: HostsBundle, id: string): string {
|
||||
function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
if (id === '')
|
||||
return ''
|
||||
if (b.workspace?.id === id)
|
||||
return b.workspace.name
|
||||
for (const w of b.available_workspaces ?? []) {
|
||||
const ctx = active.ctx
|
||||
if (ctx.workspace?.id === id)
|
||||
return ctx.workspace.name
|
||||
for (const w of ctx.available_workspaces ?? []) {
|
||||
if (w.id === id)
|
||||
return w.name
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export default class GetMember extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
format,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return table({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runGetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,7 +38,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -54,7 +55,7 @@ describe('runGetMember', () => {
|
||||
const r = await runGetMember(
|
||||
{ workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -69,7 +70,7 @@ describe('runGetMember', () => {
|
||||
await runGetMember(
|
||||
{ page: 3, limitRaw: '50' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -78,14 +79,20 @@ describe('runGetMember', () => {
|
||||
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
|
||||
})
|
||||
|
||||
it('marks no row when bundle has no account id', async () => {
|
||||
it('marks no row when active context has no account id', async () => {
|
||||
const client = fakeClient(env)
|
||||
const b = bundle()
|
||||
b.account = { id: '', email: '', name: '' }
|
||||
const a: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: '', email: '', name: '' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
},
|
||||
}
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: b,
|
||||
active: a,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -96,16 +103,16 @@ describe('runGetMember', () => {
|
||||
|
||||
it('throws when no workspace can be resolved', async () => {
|
||||
const client = fakeClient(env)
|
||||
const noWs: ActiveContext = {
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: { account: { id: 'acct-1', email: 'me@example.com', name: 'Me' } },
|
||||
}
|
||||
await expect(
|
||||
runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: {
|
||||
current_host: '',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: '', name: '' },
|
||||
},
|
||||
active: noWs,
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
envLookup: () => undefined,
|
||||
@ -132,7 +139,7 @@ describe('MemberListOutput shape', () => {
|
||||
const r = await runGetMember(
|
||||
{},
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
|
||||
@ -16,7 +16,7 @@ export type GetMemberOptions = {
|
||||
}
|
||||
|
||||
export type GetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -39,7 +39,7 @@ export async function runGetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
const limit = resolveLimit(opts.limitRaw, env)
|
||||
@ -50,7 +50,7 @@ export async function runGetMember(
|
||||
() => factory(deps.http).list(wsId, { page, limit }),
|
||||
)
|
||||
|
||||
const callerId = deps.bundle.account?.id ?? ''
|
||||
const callerId = deps.active.ctx.account?.id ?? ''
|
||||
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
|
||||
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export default class GetWorkspace extends DifyCommand {
|
||||
const { flags } = this.parse(GetWorkspace, argv)
|
||||
const format = flags.output
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runGetWorkspace({ format }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io })
|
||||
const result = await runGetWorkspace({ format }, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
if (result.kind === 'empty')
|
||||
return raw(result.message)
|
||||
return table({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import { stringifyOutput, table } from '../../../framework/output.js'
|
||||
@ -7,17 +7,18 @@ import { createClient } from '../../../http/client.js'
|
||||
import { WorkspaceListOutput } from './handlers.js'
|
||||
import { EMPTY_WORKSPACES_MESSAGE, runGetWorkspace } from './run.js'
|
||||
|
||||
const baseBundle: HostsBundle = {
|
||||
current_host: '127.0.0.1',
|
||||
const baseActive: ActiveContext = {
|
||||
host: '127.0.0.1',
|
||||
email: 'tester@dify.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
scheme: 'http',
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
}
|
||||
|
||||
describe('runGetWorkspace', () => {
|
||||
@ -35,8 +36,8 @@ describe('runGetWorkspace', () => {
|
||||
return createClient({ host: mock.url, bearer: 'dfoa_test' })
|
||||
}
|
||||
|
||||
async function render(format = '', bundle = baseBundle): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { bundle, http: http() })
|
||||
async function render(format = '', activeCtx = baseActive): Promise<string> {
|
||||
const result = await runGetWorkspace({ format }, { active: activeCtx, http: http() })
|
||||
if (result.kind === 'empty')
|
||||
return result.message
|
||||
return stringifyOutput(table({
|
||||
@ -75,8 +76,8 @@ describe('runGetWorkspace', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to bundle workspace.id when server current=false', async () => {
|
||||
const overridden: HostsBundle = { ...baseBundle, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } }
|
||||
it('falls back to active context workspace.id when server current=false', async () => {
|
||||
const overridden: ActiveContext = { ...baseActive, ctx: { ...baseActive.ctx, workspace: { id: 'ws-2', name: 'Other', role: 'normal' } } }
|
||||
const out = await render('', overridden)
|
||||
for (const line of out.split('\n')) {
|
||||
if (line.includes('ws-2'))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { runWithSpinner } from '../../../sys/io/spinner.js'
|
||||
@ -14,7 +14,7 @@ export type GetWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type GetWorkspaceDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -33,7 +33,7 @@ export async function runGetWorkspace(opts: GetWorkspaceOptions, deps: GetWorksp
|
||||
)
|
||||
if (env.workspaces.length === 0)
|
||||
return { kind: 'empty', message: EMPTY_WORKSPACES_MESSAGE }
|
||||
const currentId = deps.bundle.workspace?.id ?? ''
|
||||
const currentId = deps.active.ctx.workspace?.id ?? ''
|
||||
return {
|
||||
kind: 'output',
|
||||
data: new WorkspaceListOutput(env.workspaces.map(w => new WorkspaceRow(
|
||||
|
||||
@ -49,7 +49,7 @@ export default class ResumeApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import type { RunContext } from '../../run/app/_strategies/index.js'
|
||||
@ -30,7 +30,7 @@ export type ResumeAppOptions = {
|
||||
}
|
||||
|
||||
export type ResumeAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -78,7 +78,7 @@ async function resolveInputs(
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
@ -54,7 +54,7 @@ export default class RunApp extends DifyCommand {
|
||||
stream: flags.stream,
|
||||
think: flags.think,
|
||||
},
|
||||
{ bundle: ctx.bundle, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
{ active: ctx.active, http: ctx.http, host: ctx.host, io: ctx.io, cache: ctx.cache },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DifyMock } from '../../../../test/fixtures/dify-mock/server.js'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@ -13,17 +13,18 @@ import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { resumeApp } from '../../resume/app/run.js'
|
||||
import { runApp } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'http://localhost',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
host: 'http://localhost',
|
||||
email: 't@d.ai',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 't@d.ai', name: 'T' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Other', role: 'normal' },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +52,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: hi\n')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -62,7 +63,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', message: 'hi' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'usage_invalid_flag' })
|
||||
})
|
||||
|
||||
@ -71,7 +72,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' } },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -81,7 +82,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -92,7 +93,7 @@ describe('runApp', () => {
|
||||
const io = bufferStreams()
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', format: 'bogus' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/not supported/)
|
||||
})
|
||||
|
||||
@ -101,7 +102,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'nope', message: 'hi' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -114,7 +115,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('echo: ')
|
||||
expect(io.outBuf()).toContain('hi')
|
||||
@ -126,7 +127,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true, format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, answer: string, conversation_id: string }
|
||||
expect(parsed.mode).toBe('chat')
|
||||
@ -139,7 +140,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'do research' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('do research')
|
||||
expect(io.errBuf()).toContain('--conversation conv-1')
|
||||
@ -150,7 +151,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-4', workspace: 'ws-2', message: 'go', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toContain('go')
|
||||
expect(io.errBuf()).toContain('thought:')
|
||||
@ -161,7 +162,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { x: '1' }, stream: true, format: 'json' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
const parsed = JSON.parse(io.outBuf()) as { mode: string, data: { status: string } }
|
||||
expect(parsed.mode).toBe('workflow')
|
||||
@ -174,7 +175,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await expect(runApp(
|
||||
{ appId: 'app-1', message: 'hi', stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test', retryAttempts: 0 }), host: mock.url, io, cache },
|
||||
)).rejects.toMatchObject({ code: 'server_5xx' })
|
||||
})
|
||||
|
||||
@ -186,7 +187,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -198,7 +199,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, JSON.stringify([1, 2, 3]))
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsFile },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/must be a JSON object/)
|
||||
})
|
||||
|
||||
@ -207,7 +208,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputsJson: '{"x":"hello"}' },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
})
|
||||
@ -219,7 +220,7 @@ describe('runApp', () => {
|
||||
await writeFile(inputsFile, '{}')
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputsJson: '{}', inputsFile },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io },
|
||||
)).rejects.toThrow(/mutually exclusive/)
|
||||
})
|
||||
|
||||
@ -231,7 +232,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {} },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -260,7 +261,7 @@ describe('runApp', () => {
|
||||
await expect(runApp(
|
||||
{ appId: 'app-2', inputs: {}, format: 'json' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: createClient({ host: mock.url, bearer: 'dfoa_test' }),
|
||||
host: mock.url,
|
||||
io,
|
||||
@ -284,7 +285,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, withHistory: false },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -295,7 +296,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {} },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: resumed\n')
|
||||
})
|
||||
@ -306,7 +307,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-hitl-1', workflowRunId: 'wf-run-hitl-1', action: 'submit', inputs: {}, stream: true },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
// stream mode for workflow: node_started → "→ <title>" on stderr
|
||||
expect(io.errBuf()).toContain('After Resume')
|
||||
@ -317,7 +318,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: ['doc=https://example.com/report.pdf'] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(0)
|
||||
@ -338,7 +339,7 @@ describe('runApp', () => {
|
||||
await writeFile(filePath, 'fake pdf content')
|
||||
await runApp(
|
||||
{ appId: 'app-2', files: [`doc=@${filePath}`] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
expect(mock.uploadCallCount).toBe(1)
|
||||
@ -355,7 +356,7 @@ describe('runApp', () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await runApp(
|
||||
{ appId: 'app-2', inputs: { doc: 'old-value' }, files: ['doc=https://example.com/override.pdf'] },
|
||||
{ bundle: bundle(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
{ active: active(), http: createClient({ host: mock.url, bearer: 'dfoa_test' }), host: mock.url, io, cache },
|
||||
)
|
||||
expect(io.outBuf()).toBe('echo: \n')
|
||||
const runInputs = mock.lastRunBody?.inputs as Record<string, unknown>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { AppInfoCache } from '../../../cache/app-info.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { AppMetaClient } from '../../../api/app-meta.js'
|
||||
@ -32,7 +32,7 @@ export type RunAppOptions = {
|
||||
}
|
||||
|
||||
export type RunAppDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly host: string
|
||||
readonly io: IOStreams
|
||||
@ -80,7 +80,7 @@ async function resolveInputs(
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), bundle: deps.bundle })
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const apps = new AppsClient(deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, wsId, [FieldInfo])
|
||||
|
||||
@ -36,7 +36,7 @@ export default class SetMember extends DifyCommand {
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
|
||||
const result = await runSetMember(
|
||||
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
|
||||
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format, data: result.data })
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runSetMember } from './run.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
function active(): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'me@example.com',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +28,7 @@ describe('runSetMember', () => {
|
||||
const result = await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -46,7 +47,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: 'acct-2', role: 'owner' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -62,7 +63,7 @@ describe('runSetMember', () => {
|
||||
runSetMember(
|
||||
{ memberId: '', role: 'admin' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
@ -76,7 +77,7 @@ describe('runSetMember', () => {
|
||||
await runSetMember(
|
||||
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
|
||||
{
|
||||
bundle: bundle(),
|
||||
active: active(),
|
||||
http: {} as KyInstance,
|
||||
io: bufferStreams(),
|
||||
membersFactory: () => client as never,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { MembersClient } from '../../../api/members.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
@ -18,7 +18,7 @@ export type SetMemberOptions = {
|
||||
}
|
||||
|
||||
export type SetMemberDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
@ -59,7 +59,7 @@ export async function runSetMember(
|
||||
const wsId = resolveWorkspaceId({
|
||||
flag: opts.workspace,
|
||||
env: env('DIFY_WORKSPACE_ID'),
|
||||
bundle: deps.bundle,
|
||||
active: deps.active,
|
||||
})
|
||||
|
||||
await runWithSpinner(
|
||||
|
||||
@ -26,6 +26,8 @@ import HelpExternal from './help/external/index.js'
|
||||
import ResumeApp from './resume/app/index.js'
|
||||
import RunApp from './run/app/index.js'
|
||||
import SetMember from './set/member/index.js'
|
||||
import UseAccount from './use/account/index.js'
|
||||
import UseHost from './use/host/index.js'
|
||||
import UseWorkspace from './use/workspace/index.js'
|
||||
import Version from './version/index.js'
|
||||
|
||||
@ -104,6 +106,8 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
use: {
|
||||
subcommands: {
|
||||
account: { command: UseAccount, subcommands: {} },
|
||||
host: { command: UseHost, subcommands: {} },
|
||||
workspace: { command: UseWorkspace, subcommands: {} },
|
||||
},
|
||||
},
|
||||
|
||||
22
cli/src/commands/use/account/index.ts
Normal file
22
cli/src/commands/use/account/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
export default class UseAccount extends DifyCommand {
|
||||
static override description = 'Switch the active account on the current host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use account',
|
||||
'<%= config.bin %> use account --email bob@corp.com',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
email: Flags.string({ description: 'account email to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseAccount, argv)
|
||||
await runUseAccount({ io: realStreams(), email: flags.email !== '' ? flags.email : undefined })
|
||||
}
|
||||
}
|
||||
63
cli/src/commands/use/account/use-account.test.ts
Normal file
63
cli/src/commands/use/account/use-account.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { Key, Store } from '../../../store/store.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseAccount } from './use-account.js'
|
||||
|
||||
function memStore(seed: Record<string, string>): Store {
|
||||
const m = new Map<string, unknown>(Object.entries(seed))
|
||||
return {
|
||||
get<T>(k: Key<T>): T { return (m.get(k.key) as T | undefined) ?? k.default },
|
||||
set<T>(k: Key<T>, v: T): void { m.set(k.key, v) },
|
||||
unset<T>(k: Key<T>): void { m.delete(k.key) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('runUseAccount', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-useacct-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_account when email valid + token present', async () => {
|
||||
await runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({ 'tokens.h1.b@x': 'dfoa_b' }) })
|
||||
expect(Registry.load().hosts.h1?.current_account).toBe('b@x')
|
||||
})
|
||||
|
||||
it('errors when the account has no stored token', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'b@x', store: memStore({}) }))
|
||||
.rejects
|
||||
.toThrow(/log in|no credential/i)
|
||||
})
|
||||
|
||||
it('errors when the email is unknown on the current host', async () => {
|
||||
await expect(runUseAccount({ io: bufferStreams(), email: 'z@x', store: memStore({ 'tokens.h1.z@x': 'x' }) }))
|
||||
.rejects
|
||||
.toThrow(/unknown account|no account/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when email omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseAccount({ io, email: undefined, store: memStore({}) })).rejects.toThrow(/--email/i)
|
||||
})
|
||||
})
|
||||
76
cli/src/commands/use/account/use-account.ts
Normal file
76
cli/src/commands/use/account/use-account.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { HostEntry } from '../../../auth/hosts.js'
|
||||
import type { Store } from '../../../store/store.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { getTokenStore, tokenKey } from '../../../store/manager.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseAccountOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly email: string | undefined
|
||||
/** Optional override for tests; production resolves via `getTokenStore`. */
|
||||
readonly store?: Store
|
||||
}
|
||||
|
||||
type AccountChoice = { email: string, name: string, sso: boolean, active: boolean }
|
||||
|
||||
const USE_HOST_HINT = 'run \'difyctl use host\' or \'difyctl auth login\''
|
||||
|
||||
export async function runUseAccount(opts: UseAccountOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
if (reg.current_host === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
const host = reg.current_host
|
||||
const entry = reg.hosts[host]
|
||||
if (entry === undefined)
|
||||
throw notLoggedInError(USE_HOST_HINT)
|
||||
|
||||
const emails = Object.keys(entry.accounts)
|
||||
const target = opts.email ?? await pickAccount(opts, entry, host)
|
||||
if (!emails.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown account "${target}" on ${host}; known: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
const store = opts.store ?? getTokenStore().store
|
||||
if (store.get(tokenKey(host, target)) === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NotLoggedIn,
|
||||
message: `no credential stored for ${target} on ${host}`,
|
||||
hint: `run 'difyctl auth login --host ${host}'`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setAccount(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active account on ${host} is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickAccount(opts: UseAccountOptions, entry: HostEntry, host: string): Promise<string> {
|
||||
const emails = Object.keys(entry.accounts)
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--email is required (no TTY); known accounts on ${host}: ${emails.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: AccountChoice[] = Object.entries(entry.accounts).map(([email, ctx]) => ({
|
||||
email,
|
||||
name: ctx.account.name,
|
||||
sso: ctx.external_subject !== undefined,
|
||||
active: entry.current_account === email,
|
||||
}))
|
||||
const picked = await selectFromList<AccountChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: `Select an account on ${host}`,
|
||||
render: c => `${c.active ? '* ' : ' '}${c.email} ${c.sso ? '(SSO)' : c.name !== '' ? `(${c.name})` : ''}`.trimEnd(),
|
||||
})
|
||||
return picked.email
|
||||
}
|
||||
22
cli/src/commands/use/host/index.ts
Normal file
22
cli/src/commands/use/host/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Flags } from '../../../framework/flags.js'
|
||||
import { realStreams } from '../../../sys/io/streams'
|
||||
import { DifyCommand } from '../../_shared/dify-command.js'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
export default class UseHost extends DifyCommand {
|
||||
static override description = 'Switch the active Dify host'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> use host',
|
||||
'<%= config.bin %> use host --domain cloud.dify.ai',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
domain: Flags.string({ description: 'domain to switch to', default: '' }),
|
||||
}
|
||||
|
||||
async run(argv: string[]): Promise<void> {
|
||||
const { flags } = this.parse(UseHost, argv)
|
||||
await runUseHost({ io: realStreams(), host: flags.domain !== '' ? flags.domain : undefined })
|
||||
}
|
||||
}
|
||||
50
cli/src/commands/use/host/use-host.test.ts
Normal file
50
cli/src/commands/use/host/use-host.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams'
|
||||
import { runUseHost } from './use-host.js'
|
||||
|
||||
describe('runUseHost', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-usehost-'))
|
||||
prev = process.env[ENV_CONFIG_DIR]
|
||||
process.env[ENV_CONFIG_DIR] = dir
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h2', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.save()
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined)
|
||||
delete process.env[ENV_CONFIG_DIR]
|
||||
else process.env[ENV_CONFIG_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('switches current_host when host is valid', async () => {
|
||||
await runUseHost({ io: bufferStreams(), host: 'h2' })
|
||||
expect(Registry.load().current_host).toBe('h2')
|
||||
})
|
||||
|
||||
it('errors when host is unknown, listing valid hosts', async () => {
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'nope' })).rejects.toThrow(/h1.*h2|unknown host/i)
|
||||
})
|
||||
|
||||
it('errors in non-TTY when host omitted', async () => {
|
||||
const io = bufferStreams()
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(runUseHost({ io, host: undefined })).rejects.toThrow(/--domain/i)
|
||||
})
|
||||
|
||||
it('errors when no hosts exist', async () => {
|
||||
Registry.empty('file').save()
|
||||
await expect(runUseHost({ io: bufferStreams(), host: 'h1' })).rejects.toThrow(/no hosts|not logged in/i)
|
||||
})
|
||||
})
|
||||
54
cli/src/commands/use/host/use-host.ts
Normal file
54
cli/src/commands/use/host/use-host.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { IOStreams } from '../../../sys/io/streams'
|
||||
import { notLoggedInError, Registry } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
import { selectFromList } from '../../../sys/io/select.js'
|
||||
|
||||
export type UseHostOptions = {
|
||||
readonly io: IOStreams
|
||||
readonly host: string | undefined
|
||||
}
|
||||
|
||||
type HostChoice = { host: string, accounts: number, active: boolean }
|
||||
|
||||
export async function runUseHost(opts: UseHostOptions): Promise<void> {
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const reg = Registry.load()
|
||||
const hosts = Object.keys(reg.hosts)
|
||||
if (hosts.length === 0)
|
||||
throw notLoggedInError()
|
||||
|
||||
const target = opts.host ?? await pickHost(opts, reg, hosts)
|
||||
if (!hosts.includes(target)) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `unknown host "${target}"; known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
|
||||
reg.setHost(target)
|
||||
reg.save()
|
||||
opts.io.out.write(`${cs.successIcon()} Active host is now ${target}\n`)
|
||||
}
|
||||
|
||||
async function pickHost(opts: UseHostOptions, reg: Registry, hosts: readonly string[]): Promise<string> {
|
||||
if (!opts.io.isErrTTY) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: `--domain is required (no TTY); known hosts: ${hosts.join(', ')}`,
|
||||
})
|
||||
}
|
||||
const choices: HostChoice[] = hosts.map(h => ({
|
||||
host: h,
|
||||
accounts: Object.keys(reg.hosts[h]?.accounts ?? {}).length,
|
||||
active: reg.current_host === h,
|
||||
}))
|
||||
const picked = await selectFromList<HostChoice>({
|
||||
io: opts.io,
|
||||
items: choices,
|
||||
header: 'Select a host',
|
||||
render: c => `${c.active ? '* ' : ' '}${c.host} (${c.accounts} account${c.accounts === 1 ? '' : 's'})`,
|
||||
})
|
||||
return picked.host
|
||||
}
|
||||
@ -22,7 +22,8 @@ export default class UseWorkspace extends DifyCommand {
|
||||
const { args, flags } = this.parse(UseWorkspace, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
await runUseWorkspace({ workspaceId: args.workspaceId }, {
|
||||
bundle: ctx.bundle,
|
||||
reg: ctx.reg,
|
||||
active: ctx.active,
|
||||
http: ctx.http,
|
||||
io: ctx.io,
|
||||
})
|
||||
|
||||
@ -3,28 +3,36 @@ import type {
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext } from '../../../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
|
||||
import { Registry } from '../../../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../../../store/dir.js'
|
||||
import { bufferStreams } from '../../../sys/io/streams.js'
|
||||
import { runUseWorkspace } from './use.js'
|
||||
|
||||
function bundle(): HostsBundle {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
function makeRegistry(): Registry {
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('cloud.dify.ai', 'tester@dify.ai', {
|
||||
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
|
||||
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
|
||||
],
|
||||
}
|
||||
})
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('tester@dify.ai')
|
||||
return reg
|
||||
}
|
||||
|
||||
function makeActive(reg: Registry): ActiveContext {
|
||||
const active = reg.resolveActive()
|
||||
if (active === undefined)
|
||||
throw new Error('resolveActive returned undefined in test setup')
|
||||
return active
|
||||
}
|
||||
|
||||
function fakeClient(opts: {
|
||||
@ -68,14 +76,16 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
const next = await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -84,40 +94,65 @@ describe('runUseWorkspace', () => {
|
||||
|
||||
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
|
||||
expect(client.list).toHaveBeenCalledOnce()
|
||||
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(next.available_workspaces).toEqual([
|
||||
|
||||
const activeCtx = next.resolveActive()
|
||||
expect(activeCtx?.ctx.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
|
||||
expect(activeCtx?.ctx.available_workspaces).toEqual([
|
||||
{ id: 'ws-1', name: 'Default', role: 'owner' },
|
||||
{ id: 'ws-2', name: 'Switched', role: 'normal' },
|
||||
])
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.id).toBe('ws-2')
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.id).toBe('ws-2')
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
|
||||
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// bundle has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveHosts to record the fresh name from the server.
|
||||
it('hosts.yml contains no bearer after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = loadHosts()
|
||||
expect(reloaded?.workspace?.name).toBe('Switched')
|
||||
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
const reloaded = Registry.load()
|
||||
const raw = JSON.stringify(reloaded)
|
||||
expect(raw).not.toMatch(/bearer/)
|
||||
})
|
||||
|
||||
it('refreshes stale workspace name from server', async () => {
|
||||
// registry has ws-2 named "Stale Name"; server returns "Switched".
|
||||
// We expect saveRegistry to record the fresh name from the server.
|
||||
const io = bufferStreams()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const client = fakeClient({})
|
||||
|
||||
await runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{ reg, active, http: {} as KyInstance, io, workspacesFactory: () => client as never },
|
||||
)
|
||||
|
||||
const reloaded = Registry.load()
|
||||
const reloadedActive = reloaded?.resolveActive()
|
||||
expect(reloadedActive?.ctx.workspace?.name).toBe('Switched')
|
||||
expect(reloadedActive?.ctx.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.reject(new Error('forbidden')),
|
||||
@ -127,7 +162,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -136,16 +172,18 @@ describe('runUseWorkspace', () => {
|
||||
).rejects.toThrow(/forbidden/)
|
||||
|
||||
expect(client.list).not.toHaveBeenCalled()
|
||||
const after = loadHosts()
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
expect(after?.workspace?.id).toBe('ws-1')
|
||||
const afterActive = after?.resolveActive()
|
||||
expect(afterActive?.ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const before = loadHosts()
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
const before = Registry.load()
|
||||
|
||||
const client = fakeClient({
|
||||
list: () => Promise.reject(new Error('transient list failure')),
|
||||
@ -155,7 +193,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-2' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
@ -163,14 +202,15 @@ describe('runUseWorkspace', () => {
|
||||
),
|
||||
).rejects.toThrow(/transient list failure/)
|
||||
|
||||
const after = loadHosts()
|
||||
const after = Registry.load()
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
|
||||
const io = bufferStreams()
|
||||
const b = bundle()
|
||||
saveHosts(b)
|
||||
const reg = makeRegistry()
|
||||
reg.save()
|
||||
const active = makeActive(reg)
|
||||
|
||||
const client = fakeClient({
|
||||
switch: () => Promise.resolve({
|
||||
@ -192,7 +232,8 @@ describe('runUseWorkspace', () => {
|
||||
runUseWorkspace(
|
||||
{ workspaceId: 'ws-7' },
|
||||
{
|
||||
bundle: b,
|
||||
reg,
|
||||
active,
|
||||
http: {} as KyInstance,
|
||||
io,
|
||||
workspacesFactory: () => client as never,
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { KyInstance } from 'ky'
|
||||
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
|
||||
import type { ActiveContext, Registry, Workspace } from '../../../auth/hosts.js'
|
||||
import type { IOStreams } from '../../../sys/io/streams.js'
|
||||
import { WorkspacesClient } from '../../../api/workspaces.js'
|
||||
import { saveHosts } from '../../../auth/hosts.js'
|
||||
import { BaseError } from '../../../errors/base.js'
|
||||
import { ErrorCode } from '../../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from '../../../sys/io/color.js'
|
||||
@ -13,7 +12,8 @@ export type UseWorkspaceOptions = {
|
||||
}
|
||||
|
||||
export type UseWorkspaceDeps = {
|
||||
readonly bundle: HostsBundle
|
||||
readonly reg: Registry
|
||||
readonly active: ActiveContext
|
||||
readonly http: KyInstance
|
||||
readonly io: IOStreams
|
||||
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
|
||||
@ -31,12 +31,12 @@ export type UseWorkspaceDeps = {
|
||||
* stays in sync. Failure here also aborts; the server-side current has
|
||||
* already moved, but the local file is left untouched. A follow-up
|
||||
* `difyctl get workspace` will reconcile.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
|
||||
* 3. Persist `workspace` + `available_workspaces` atomically via `saveRegistry`.
|
||||
*/
|
||||
export async function runUseWorkspace(
|
||||
opts: UseWorkspaceOptions,
|
||||
deps: UseWorkspaceDeps,
|
||||
): Promise<HostsBundle> {
|
||||
): Promise<Registry> {
|
||||
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
|
||||
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
|
||||
const client = factory(deps.http)
|
||||
@ -60,16 +60,13 @@ export async function runUseWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
const next: HostsBundle = {
|
||||
...deps.bundle,
|
||||
const nextCtx = {
|
||||
...deps.active.ctx,
|
||||
workspace: { id: matched.id, name: matched.name, role: matched.role },
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
role: w.role,
|
||||
})),
|
||||
available_workspaces: list.workspaces.map<Workspace>(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
}
|
||||
saveHosts(next)
|
||||
deps.reg.upsert(deps.active.host, deps.active.email, nextCtx)
|
||||
deps.reg.save()
|
||||
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
|
||||
return next
|
||||
return deps.reg
|
||||
}
|
||||
|
||||
95
cli/src/sys/io/select.test.ts
Normal file
95
cli/src/sys/io/select.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectFromList } from './select'
|
||||
import { bufferStreams } from './streams'
|
||||
|
||||
type Row = { id: string, label: string }
|
||||
const rows: Row[] = [
|
||||
{ id: '1', label: 'alpha' },
|
||||
{ id: '2', label: 'beta' },
|
||||
{ id: '3', label: 'gamma' },
|
||||
]
|
||||
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
|
||||
type FakeTTYIn = PassThrough & { isTTY: boolean, isRaw: boolean, setRawMode: (mode: boolean) => unknown }
|
||||
|
||||
function ttyInput(opts: { failRawMode?: boolean } = {}): FakeTTYIn {
|
||||
const stream = new PassThrough() as unknown as FakeTTYIn
|
||||
stream.isTTY = true
|
||||
stream.isRaw = false
|
||||
stream.setRawMode = (mode: boolean): unknown => {
|
||||
if (opts.failRawMode === true && mode)
|
||||
throw new Error('raw mode unavailable')
|
||||
stream.isRaw = mode
|
||||
return stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
function ttyStreams(input: FakeTTYIn): ReturnType<typeof bufferStreams> {
|
||||
const io = bufferStreams()
|
||||
;(io as { in: NodeJS.ReadableStream }).in = input
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = true
|
||||
return io
|
||||
}
|
||||
|
||||
describe('selectFromList (non-TTY numbered fallback)', () => {
|
||||
it('returns the item matching the typed number', async () => {
|
||||
const io = bufferStreams('2\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
const picked = await selectFromList({ io, items: rows, header: 'Pick one', render: r => r.label })
|
||||
expect(picked.id).toBe('2')
|
||||
expect(io.errBuf()).toContain('1) alpha')
|
||||
expect(io.errBuf()).toContain('Pick one')
|
||||
})
|
||||
|
||||
it('rejects an out-of-range selection', async () => {
|
||||
const io = bufferStreams('9\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/invalid selection/i)
|
||||
})
|
||||
|
||||
it('throws when the list is empty', async () => {
|
||||
const io = bufferStreams('1\n')
|
||||
;(io as { isErrTTY: boolean }).isErrTTY = false
|
||||
await expect(selectFromList({ io, items: [] as Row[], header: 'Pick', render: r => (r as Row).label }))
|
||||
.rejects
|
||||
.toThrow(/nothing to select/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectFromList (interactive TTY picker)', () => {
|
||||
it('moves with arrow keys and resolves on enter, restoring raw mode', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B[B')
|
||||
input.write('\r')
|
||||
const picked = await pick
|
||||
expect(picked.id).toBe('2')
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
|
||||
it('cancels on escape', async () => {
|
||||
const input = ttyInput()
|
||||
const io = ttyStreams(input)
|
||||
const pick = selectFromList({ io, items: rows, header: 'Pick', render: r => r.label })
|
||||
input.write('\x1B')
|
||||
await expect(pick).rejects.toThrow(/cancelled/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects and restores the terminal when raw-mode setup fails', async () => {
|
||||
const input = ttyInput({ failRawMode: true })
|
||||
const io = ttyStreams(input)
|
||||
await expect(selectFromList({ io, items: rows, header: 'Pick', render: r => r.label }))
|
||||
.rejects
|
||||
.toThrow(/raw mode unavailable/i)
|
||||
expect(input.isRaw).toBe(false)
|
||||
expect(io.errBuf()).toContain(SHOW_CURSOR)
|
||||
})
|
||||
})
|
||||
153
cli/src/sys/io/select.ts
Normal file
153
cli/src/sys/io/select.ts
Normal file
@ -0,0 +1,153 @@
|
||||
import type { Key } from 'node:readline'
|
||||
import type { IOStreams } from './streams'
|
||||
import * as readline from 'node:readline'
|
||||
import { BaseError } from '../../errors/base.js'
|
||||
import { ErrorCode } from '../../errors/codes.js'
|
||||
import { colorEnabled, colorScheme } from './color.js'
|
||||
|
||||
export type SelectOptions<T> = {
|
||||
readonly io: IOStreams
|
||||
readonly items: readonly T[]
|
||||
readonly header: string
|
||||
/** Single rich line shown per option. */
|
||||
readonly render: (item: T) => string
|
||||
/** Optional second line shown only for the focused option in the TTY picker. */
|
||||
readonly describe?: (item: T) => string
|
||||
}
|
||||
|
||||
const HIDE_CURSOR = '\x1B[?25l'
|
||||
const SHOW_CURSOR = '\x1B[?25h'
|
||||
const CLEAR_DOWN = '\x1B[0J'
|
||||
const cursorUp = (n: number): string => `\x1B[${n}A`
|
||||
|
||||
export async function selectFromList<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
if (opts.items.length === 0)
|
||||
throw new BaseError({ code: ErrorCode.UsageMissingArg, message: 'nothing to select' })
|
||||
return opts.io.isErrTTY ? pickInteractive(opts) : pickNumbered(opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow-key picker built on Node's readline keypress events — no third-party
|
||||
* prompt library, so it bundles cleanly into the compiled binary. Renders to
|
||||
* the err stream, redrawing in place on each keystroke and erasing itself on
|
||||
* exit so the caller's own output starts on a clean row.
|
||||
*/
|
||||
async function pickInteractive<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
const input = opts.io.in as NodeJS.ReadStream
|
||||
const out = opts.io.err
|
||||
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
|
||||
const count = opts.items.length
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = 0
|
||||
let rendered = 0
|
||||
|
||||
const frame = (): readonly string[] => {
|
||||
const lines = [opts.header]
|
||||
opts.items.forEach((item, i) => {
|
||||
const focused = i === active
|
||||
const pointer = focused ? cs.cyan('❯') : ' '
|
||||
const label = focused ? cs.bold(opts.render(item)) : opts.render(item)
|
||||
lines.push(`${pointer} ${label}`)
|
||||
})
|
||||
const desc = opts.describe?.(opts.items[active] as T)
|
||||
if (desc !== undefined && desc !== '')
|
||||
lines.push(cs.dim(` ${desc}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
const render = (): void => {
|
||||
if (rendered > 0)
|
||||
out.write(cursorUp(rendered))
|
||||
const lines = frame()
|
||||
out.write(`${CLEAR_DOWN}${lines.join('\n')}\n`)
|
||||
rendered = lines.length
|
||||
}
|
||||
|
||||
const wasRaw = input.isTTY ? input.isRaw : false
|
||||
const cleanup = (): void => {
|
||||
input.off('keypress', onKey)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(wasRaw)
|
||||
input.pause()
|
||||
if (rendered > 0)
|
||||
out.write(`${cursorUp(rendered)}${CLEAR_DOWN}`)
|
||||
out.write(SHOW_CURSOR)
|
||||
}
|
||||
|
||||
function onKey(_str: string | undefined, key: Key): void {
|
||||
if (key.ctrl && key.name === 'c') {
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
return
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'up':
|
||||
case 'k':
|
||||
active = (active - 1 + count) % count
|
||||
render()
|
||||
break
|
||||
case 'down':
|
||||
case 'j':
|
||||
active = (active + 1) % count
|
||||
render()
|
||||
break
|
||||
case 'return':
|
||||
case 'enter': {
|
||||
const chosen = opts.items[active]
|
||||
cleanup()
|
||||
if (chosen === undefined)
|
||||
reject(new BaseError({ code: ErrorCode.UsageInvalidFlag, message: 'invalid selection' }))
|
||||
else
|
||||
resolve(chosen)
|
||||
break
|
||||
}
|
||||
case 'escape':
|
||||
cleanup()
|
||||
reject(cancelled())
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
readline.emitKeypressEvents(input)
|
||||
if (input.isTTY)
|
||||
input.setRawMode(true)
|
||||
out.write(HIDE_CURSOR)
|
||||
input.on('keypress', onKey)
|
||||
input.resume()
|
||||
render()
|
||||
}
|
||||
catch (err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cancelled(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.UsageMissingArg, message: 'selection cancelled' })
|
||||
}
|
||||
|
||||
async function pickNumbered<T>(opts: SelectOptions<T>): Promise<T> {
|
||||
opts.io.err.write(`${opts.header}\n`)
|
||||
opts.items.forEach((item, idx) => {
|
||||
opts.io.err.write(` ${idx + 1}) ${opts.render(item)}\n`)
|
||||
})
|
||||
opts.io.err.write('Enter number: ')
|
||||
|
||||
const rl = readline.createInterface({ input: opts.io.in, output: opts.io.err, terminal: false })
|
||||
try {
|
||||
const line: string = await new Promise(resolve => rl.once('line', resolve))
|
||||
const n = Number(line.trim())
|
||||
const chosen = Number.isInteger(n) ? opts.items[n - 1] : undefined
|
||||
if (chosen === undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `invalid selection: ${line.trim()}` })
|
||||
return chosen
|
||||
}
|
||||
finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,30 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { platform, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { startMock } from '../../test/fixtures/dify-mock/server.js'
|
||||
import { saveHosts } from '../auth/hosts.js'
|
||||
import { Registry } from '../auth/hosts.js'
|
||||
import { ENV_CONFIG_DIR } from '../store/dir.js'
|
||||
import { arch } from '../sys/index.js'
|
||||
import { runVersionProbe } from './probe.js'
|
||||
|
||||
function bundle(overrides: Partial<HostsBundle> = {}): HostsBundle {
|
||||
function active(overrides: Partial<ActiveContext> = {}): ActiveContext {
|
||||
return {
|
||||
current_host: 'cloud.dify.ai',
|
||||
host: 'cloud.dify.ai',
|
||||
email: 'test@dify.ai',
|
||||
ctx: { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } },
|
||||
scheme: 'https',
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
...overrides,
|
||||
} as HostsBundle
|
||||
}
|
||||
}
|
||||
|
||||
describe('runVersionProbe', () => {
|
||||
it('returns skipped server + unknown compat when skipServer=true', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -38,7 +38,7 @@ describe('runVersionProbe', () => {
|
||||
let observed: string | undefined
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }),
|
||||
loadActive: async () => active(),
|
||||
probe: async (endpoint) => {
|
||||
observed = endpoint
|
||||
return { version: '1.6.4', edition: 'CLOUD' }
|
||||
@ -49,10 +49,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('compatible')
|
||||
})
|
||||
|
||||
it('returns no-host + unknown compat when bundle is missing', async () => {
|
||||
it('returns no-host + unknown compat when active context is missing', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -61,10 +61,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('no host')
|
||||
})
|
||||
|
||||
it('returns no-host when bundle has empty current_host', async () => {
|
||||
it('returns no-host when active context has empty host', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ current_host: '' }),
|
||||
loadActive: async () => active({ host: '' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -72,10 +72,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => {
|
||||
it('distinguishes loadActive disk failure from no-host configured in the detail', async () => {
|
||||
const errReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => { throw new Error('disk-explode') },
|
||||
loadActive: async () => { throw new Error('disk-explode') },
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(errReport.server.reachable).toBe(false)
|
||||
@ -84,7 +84,7 @@ describe('runVersionProbe', () => {
|
||||
|
||||
const noHostReport = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
expect(noHostReport.compat.detail).toContain('no host')
|
||||
@ -94,7 +94,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns compatible report when server is reachable and in range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
@ -108,7 +108,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unsupported when server version is out of range', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -119,7 +119,7 @@ describe('runVersionProbe', () => {
|
||||
it('returns unknown when server returns an empty version string', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async (): Promise<ServerVersionResponse> => ({ version: '', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -130,7 +130,7 @@ describe('runVersionProbe', () => {
|
||||
it('treats probe rejection as unreachable + unknown compat', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle(),
|
||||
loadActive: async () => active(),
|
||||
probe: async () => { throw new Error('timeout') },
|
||||
})
|
||||
|
||||
@ -141,10 +141,10 @@ describe('runVersionProbe', () => {
|
||||
expect(report.compat.detail).toContain('unreachable')
|
||||
})
|
||||
|
||||
it('builds endpoint using bundle scheme when host has no scheme', async () => {
|
||||
it('builds endpoint using active scheme when host has no scheme', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: false,
|
||||
loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }),
|
||||
loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }),
|
||||
probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }),
|
||||
})
|
||||
|
||||
@ -161,12 +161,12 @@ describe('runVersionProbe', () => {
|
||||
const prevConfig = process.env[ENV_CONFIG_DIR]
|
||||
try {
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
saveHosts({
|
||||
current_host: url.host,
|
||||
scheme: url.protocol.replace(':', ''),
|
||||
token_storage: 'file',
|
||||
tokens: { bearer: 'dfoa_test' },
|
||||
})
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert(url.host, 'test@dify.ai', { account: { id: 'acct-1', email: 'test@dify.ai', name: 'Test' } })
|
||||
reg.setHost(url.host)
|
||||
reg.setAccount('test@dify.ai')
|
||||
reg.setScheme(url.host, url.protocol.replace(':', ''))
|
||||
reg.save()
|
||||
process.env[ENV_CONFIG_DIR] = configDir
|
||||
|
||||
const report = await runVersionProbe({ skipServer: false })
|
||||
@ -190,7 +190,7 @@ describe('runVersionProbe', () => {
|
||||
it('always includes client metadata in the report', async () => {
|
||||
const report = await runVersionProbe({
|
||||
skipServer: true,
|
||||
loadBundle: async () => undefined,
|
||||
loadActive: async () => undefined,
|
||||
probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import type { CompatVerdict } from './compat.js'
|
||||
import type { Channel } from './info.js'
|
||||
import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js'
|
||||
import { loadHosts } from '../auth/hosts.js'
|
||||
import { Registry } from '../auth/hosts.js'
|
||||
import { createClient } from '../http/client.js'
|
||||
import { arch, platform } from '../sys/index.js'
|
||||
import { hostWithScheme } from '../util/host.js'
|
||||
@ -43,11 +43,13 @@ export type MetaProbe = (endpoint: string) => Promise<ServerVersionResponse>
|
||||
|
||||
export type RunVersionProbeOptions = {
|
||||
readonly skipServer: boolean
|
||||
readonly loadBundle?: () => Promise<HostsBundle | undefined>
|
||||
readonly loadActive?: () => Promise<ActiveContext | undefined>
|
||||
readonly probe?: MetaProbe
|
||||
}
|
||||
|
||||
const defaultLoadBundle = async (): Promise<HostsBundle | undefined> => loadHosts()
|
||||
const defaultLoadActive = async (): Promise<ActiveContext | undefined> => {
|
||||
return Registry.load().resolveActive()
|
||||
}
|
||||
|
||||
const defaultProbe: MetaProbe = async (endpoint) => {
|
||||
const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 })
|
||||
@ -89,19 +91,19 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const loadBundle = opts.loadBundle ?? defaultLoadBundle
|
||||
const loadActive = opts.loadActive ?? defaultLoadActive
|
||||
const probe = opts.probe ?? defaultProbe
|
||||
|
||||
let bundle: HostsBundle | undefined
|
||||
let active: ActiveContext | undefined
|
||||
let loadFailed = false
|
||||
try {
|
||||
bundle = await loadBundle()
|
||||
active = await loadActive()
|
||||
}
|
||||
catch {
|
||||
loadFailed = true
|
||||
}
|
||||
|
||||
if (bundle === undefined || bundle.current_host === '') {
|
||||
if (active === undefined || active.host === '') {
|
||||
const detail = loadFailed ? 'hosts file unreadable' : 'no host configured'
|
||||
return {
|
||||
client,
|
||||
@ -110,7 +112,7 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise<Ver
|
||||
}
|
||||
}
|
||||
|
||||
const endpoint = hostWithScheme(bundle.current_host, bundle.scheme)
|
||||
const endpoint = hostWithScheme(active.host, active.scheme)
|
||||
|
||||
let serverInfo: ServerVersionResponse | undefined
|
||||
try {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { HostsBundle } from '../auth/hosts.js'
|
||||
import type { ActiveContext } from '../auth/hosts.js'
|
||||
import { BaseError } from '../errors/base.js'
|
||||
import { ErrorCode } from '../errors/codes.js'
|
||||
|
||||
export type WorkspaceResolveInputs = {
|
||||
readonly flag?: string
|
||||
readonly env?: string
|
||||
readonly bundle?: HostsBundle
|
||||
readonly active?: ActiveContext
|
||||
}
|
||||
|
||||
export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
@ -13,13 +13,13 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
|
||||
return inputs.flag
|
||||
if (truthy(inputs.env))
|
||||
return inputs.env
|
||||
const b = inputs.bundle
|
||||
if (b !== undefined) {
|
||||
if (truthy(b.workspace?.id))
|
||||
return b.workspace.id
|
||||
if (b.available_workspaces !== undefined && b.available_workspaces.length > 0
|
||||
&& truthy(b.available_workspaces[0]?.id)) {
|
||||
return b.available_workspaces[0].id
|
||||
const ctx = inputs.active?.ctx
|
||||
if (ctx !== undefined) {
|
||||
if (truthy(ctx.workspace?.id))
|
||||
return ctx.workspace.id
|
||||
if (ctx.available_workspaces !== undefined && ctx.available_workspaces.length > 0
|
||||
&& truthy(ctx.available_workspaces[0]?.id)) {
|
||||
return ctx.available_workspaces[0].id
|
||||
}
|
||||
}
|
||||
throw new BaseError({
|
||||
|
||||
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
1
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -1,6 +1,7 @@
|
||||
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,6 +362,16 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
|
||||
token_id: 'tok-sso-1',
|
||||
})
|
||||
}
|
||||
if (scenario === 'no-email') {
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
account: { id: ACCOUNT.id, email: '', name: '' },
|
||||
workspaces: WORKSPACES.map(w => ({ id: w.id, name: w.name, role: w.role })),
|
||||
default_workspace_id: 'ws-1',
|
||||
token_id: 'tok-1',
|
||||
})
|
||||
}
|
||||
return c.json({
|
||||
token: 'dfoa_test',
|
||||
subject_type: 'account',
|
||||
|
||||
@ -7,13 +7,9 @@ When('I open the publish panel', async function (this: DifyWorld) {
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
await this.getPage()
|
||||
.getByRole('button', { name: /Publish Update/ })
|
||||
.click()
|
||||
await this.getPage().getByRole('button', { name: /Publish Update/ }).click()
|
||||
})
|
||||
|
||||
Then('the app should be marked as published', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(this.getPage().getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import {
|
||||
createTestApp,
|
||||
enableAppSiteAndGetURL,
|
||||
publishWorkflowApp,
|
||||
syncRunnableWorkflowDraft,
|
||||
} from '../../../support/api'
|
||||
import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api'
|
||||
|
||||
When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name available. Run "a \\"workflow\\" app has been created via API" first.',
|
||||
)
|
||||
}
|
||||
if (!appName)
|
||||
throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.')
|
||||
|
||||
await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click()
|
||||
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
|
||||
@ -36,11 +28,8 @@ Given('a workflow app has been published and shared via API', async function (th
|
||||
})
|
||||
|
||||
When('I open the shared app URL', async function (this: DifyWorld) {
|
||||
if (!this.shareURL) {
|
||||
throw new Error(
|
||||
'No share URL available. Run "a workflow app has been published and shared via API" first.',
|
||||
)
|
||||
}
|
||||
if (!this.shareURL)
|
||||
throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.')
|
||||
await this.getPage().goto(this.shareURL, { timeout: 20_000 })
|
||||
})
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
|
||||
|
||||
When('I run the workflow', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const testRunButton = page.getByRole('button', { name: /Test Run/ })
|
||||
const testRunButton = page.getByText('Test Run')
|
||||
|
||||
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
|
||||
await testRunButton.click()
|
||||
@ -20,6 +20,6 @@ When('I run the workflow', async function (this: DifyWorld) {
|
||||
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('DETAIL', { exact: true }).click()
|
||||
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
|
||||
await page.getByText('DETAIL').click()
|
||||
await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 })
|
||||
})
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -510,9 +510,6 @@ catalogs:
|
||||
scheduler:
|
||||
specifier: 0.27.0
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 0.0.1
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 0.34.5
|
||||
version: 0.34.5
|
||||
@ -1245,9 +1242,6 @@ importers:
|
||||
scheduler:
|
||||
specifier: 'catalog:'
|
||||
version: 0.27.0
|
||||
server-only:
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.1
|
||||
sharp:
|
||||
specifier: 'catalog:'
|
||||
version: 0.34.5
|
||||
@ -8419,9 +8413,6 @@ packages:
|
||||
resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
server-only@0.0.1:
|
||||
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||
|
||||
sharp@0.34.5:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@ -16650,8 +16641,6 @@ snapshots:
|
||||
|
||||
seroval@1.5.1: {}
|
||||
|
||||
server-only@0.0.1: {}
|
||||
|
||||
sharp@0.34.5:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
@ -17769,7 +17758,6 @@ time:
|
||||
remark-breaks@4.0.0: '2023-09-22T16:45:41.061Z'
|
||||
remark-directive@4.0.0: '2025-02-27T15:15:20.630Z'
|
||||
scheduler@0.27.0: '2025-10-01T21:39:15.208Z'
|
||||
server-only@0.0.1: '2022-09-03T01:07:26.139Z'
|
||||
sharp@0.34.5: '2025-11-06T14:19:40.989Z'
|
||||
shiki@4.1.0: '2026-05-19T07:51:34.358Z'
|
||||
socket.io-client@4.8.3: '2025-12-23T16:39:16.428Z'
|
||||
|
||||
@ -213,7 +213,6 @@ catalog:
|
||||
remark-breaks: 4.0.0
|
||||
remark-directive: 4.0.0
|
||||
scheduler: 0.27.0
|
||||
server-only: 0.0.1
|
||||
sharp: 0.34.5
|
||||
shiki: 4.1.0
|
||||
socket.io-client: 4.8.3
|
||||
|
||||
@ -4,9 +4,6 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT
|
||||
NEXT_PUBLIC_EDITION=SELF_HOSTED
|
||||
# The base path for the application
|
||||
NEXT_PUBLIC_BASE_PATH=
|
||||
# Server-only console API origin for server-side requests.
|
||||
# Usually matches CONSOLE_API_URL from Docker deployment; local dev can rely on NEXT_PUBLIC_API_PREFIX fallback.
|
||||
CONSOLE_API_URL=http://localhost:5001
|
||||
# The base URL of console application, refers to the Console base URL of WEB service if console domain is
|
||||
# different from api or web app domain.
|
||||
# example: https://cloud.dify.ai/console/api
|
||||
|
||||
@ -141,6 +141,7 @@ describe('Header Account Dropdown Flow', () => {
|
||||
})
|
||||
|
||||
it('logs out, resets cached user markers, and redirects to signin', async () => {
|
||||
localStorage.setItem('setup_status', 'done')
|
||||
localStorage.setItem('education-reverify-prev-expire-at', '1')
|
||||
localStorage.setItem('education-reverify-has-noticed', '1')
|
||||
localStorage.setItem('education-expired-has-noticed', '1')
|
||||
@ -156,6 +157,7 @@ describe('Header Account Dropdown Flow', () => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
expect(localStorage.getItem('setup_status')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-prev-expire-at')).toBeNull()
|
||||
expect(localStorage.getItem('education-reverify-has-noticed')).toBeNull()
|
||||
expect(localStorage.getItem('education-expired-has-noticed')).toBeNull()
|
||||
|
||||
@ -1,124 +0,0 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryClient: undefined as QueryClient | undefined,
|
||||
profileQueryFn: vi.fn(),
|
||||
systemFeaturesQueryFn: vi.fn(),
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${url}`)
|
||||
}),
|
||||
headers: vi.fn(),
|
||||
resolveServerConsoleApiUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
getQueryClientServer: () => mocks.queryClient,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: () => mocks.headers(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
redirect: (url: string) => mocks.redirect(url),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/server', () => ({
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
serverUserProfileQueryOptions: () => ({
|
||||
queryKey: ['common', 'user-profile'],
|
||||
queryFn: mocks.profileQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/system-features', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CommonLayoutHydrationBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.headers.mockResolvedValue(new Headers({
|
||||
'x-dify-pathname': '/apps',
|
||||
'x-dify-search': '?tag=workflow',
|
||||
}))
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
|
||||
mocks.profileQueryFn.mockResolvedValue({
|
||||
profile: {
|
||||
id: 'account-id',
|
||||
name: 'Dify User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
is_password_set: true,
|
||||
},
|
||||
meta: {
|
||||
currentVersion: '1.0.0',
|
||||
currentEnv: 'DEVELOPMENT',
|
||||
},
|
||||
})
|
||||
mocks.systemFeaturesQueryFn.mockResolvedValue({ branding: { enabled: false } })
|
||||
})
|
||||
|
||||
it('should hydrate common layout queries and render children', async () => {
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.systemFeaturesQueryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2Fapps%3Ftag%3Dworkflow')
|
||||
})
|
||||
|
||||
it('should redirect setup errors to install', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/install')
|
||||
})
|
||||
|
||||
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
|
||||
mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
children: <div>Common shell</div>,
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{element as ReactElement}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
expect(screen.getByText('Common shell')).toBeInTheDocument()
|
||||
expect(mocks.profileQueryFn).not.toHaveBeenCalled()
|
||||
expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -25,7 +25,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import { getLocalStorageItem, useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@ -55,14 +54,13 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setEventHideHeader(v.payload)
|
||||
setHideHeader(v.payload)
|
||||
})
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
|
||||
@ -127,7 +125,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = getLocalStorageItem('app-detail-collapse-or-expand', 'expand') || 'expand'
|
||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import EducationApplyPage from '@/app/education-apply/education-apply-page'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import {
|
||||
useRouter,
|
||||
@ -28,7 +28,7 @@ export default function EducationApply() {
|
||||
}, [enableEducationPlan, isFetchedPlanInfo, router, token])
|
||||
|
||||
if (!isFetchedPlanInfo || !enableEducationPlan || !token || isLoadingEducationAccountInfo)
|
||||
return <FullScreenLoading />
|
||||
return <RootLoading />
|
||||
|
||||
return <EducationApplyPage />
|
||||
}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FullScreenLoading } from '@/app/components/full-screen-loading'
|
||||
import { isLegacyBase401 } from '@/features/account-profile/client'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { isLegacyBase401 } from '@/service/use-common'
|
||||
|
||||
type Props = {
|
||||
error: Error & { digest?: string }
|
||||
@ -18,7 +18,7 @@ export default function CommonLayoutError({ error, unstable_retry }: Props) {
|
||||
// Showing the "Try again" button here would just flash for a few frames before
|
||||
// the page navigates away, and clicking it would 401 again anyway.
|
||||
if (isLegacyBase401(error))
|
||||
return <FullScreenLoading />
|
||||
return <RootLoading />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background-body">
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
|
||||
const CURRENT_SEARCH_HEADER = 'x-dify-search'
|
||||
const ACCOUNT_PROFILE_PATH = '/account/profile'
|
||||
const AUTH_REFRESH_PATH = '/auth/refresh'
|
||||
|
||||
type ConsoleErrorPayload = {
|
||||
code?: string
|
||||
}
|
||||
|
||||
const isConsoleErrorPayload = (value: unknown): value is ConsoleErrorPayload =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPayload | null> => {
|
||||
try {
|
||||
const payload: unknown = await error.clone().json()
|
||||
return isConsoleErrorPayload(payload) ? payload : null
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentPath = async () => {
|
||||
const requestHeaders = await headers()
|
||||
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/apps`
|
||||
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
|
||||
return `${pathname}${search}`
|
||||
}
|
||||
|
||||
const redirectToAuthRefresh = async () => {
|
||||
const currentPath = await getCurrentPath()
|
||||
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
|
||||
}
|
||||
|
||||
const handleProfileError = async (error: unknown) => {
|
||||
if (!(error instanceof Response))
|
||||
throw error
|
||||
|
||||
const errorData = await parseConsoleErrorPayload(error)
|
||||
if (errorData?.code === 'not_setup')
|
||||
redirect(`${basePath}/install`)
|
||||
if (errorData?.code === 'not_init_validated')
|
||||
redirect(`${basePath}/init`)
|
||||
if (error.status === 401)
|
||||
await redirectToAuthRefresh()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
|
||||
const queryClient = getQueryClientServer()
|
||||
const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
|
||||
|
||||
if (!accountProfileUrl) {
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.fetchQuery(serverUserProfileQueryOptions()),
|
||||
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
|
||||
])
|
||||
}
|
||||
catch (error) {
|
||||
await handleProfileError(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
{children}
|
||||
</HydrationBoundary>
|
||||
)
|
||||
}
|
||||
@ -1,31 +1,27 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
import Header from '@/app/components/header'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import PartnerStack from '../components/billing/partner-stack'
|
||||
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
|
||||
import RoleRouteGuard from './role-route-guard'
|
||||
|
||||
const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
@ -44,8 +40,8 @@ const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
<Zendesk />
|
||||
<Zendesk />
|
||||
</AppInitializer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
9
web/app/(commonLayout)/loading.tsx
Normal file
9
web/app/(commonLayout)/loading.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
export default function CommonLayoutLoading() {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -216,6 +216,7 @@ const EmailChangeModal = ({ onClose, email }: Props) => {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
router.push('/signin')
|
||||
|
||||
@ -16,10 +16,10 @@ import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import Collapse from '@/app/components/header/account-setting/collapse'
|
||||
import { IS_CE_EDITION, validPassword } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common'
|
||||
import DeleteAccount from '../delete-account'
|
||||
|
||||
import AvatarWithEdit from './AvatarWithEdit'
|
||||
@ -49,7 +49,7 @@ export default function AccountPage() {
|
||||
// Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously.
|
||||
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
|
||||
const userProfile = userProfileResp.profile
|
||||
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
|
||||
const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile })
|
||||
const { isEducationAccount } = useProviderContext()
|
||||
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
@ -12,9 +12,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function AppSelector() {
|
||||
const router = useRouter()
|
||||
@ -32,6 +31,7 @@ export default function AppSelector() {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
|
||||
localStorage.removeItem('setup_status')
|
||||
resetUser()
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
|
||||
@ -1,25 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
|
||||
import { AppInitializer } from '@/app/components/app-initializer'
|
||||
import AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { ProviderContextProvider } from '@/context/provider-context-provider'
|
||||
import Header from './header'
|
||||
|
||||
const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<GoogleAnalyticsScripts />
|
||||
<AmplitudeProvider />
|
||||
<OAuthRegistrationAnalytics />
|
||||
<EducationVerifyActionRecorder />
|
||||
<CommonLayoutHydrationBoundary>
|
||||
<AppInitializer>
|
||||
<AppContextProvider>
|
||||
<EventEmitterContextProvider>
|
||||
<ProviderContextProvider>
|
||||
@ -34,7 +30,7 @@ const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
</AppContextProvider>
|
||||
</CommonLayoutHydrationBoundary>
|
||||
</AppInitializer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,9 +5,9 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Header from '@/app/signin/_header'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
|
||||
|
||||
export default function SignInLayout({ children }: any) {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
|
||||
@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { isLegacyBase401, userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common'
|
||||
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
|
||||
|
||||
function buildReturnUrl(pathname: string, search: string) {
|
||||
@ -80,6 +80,7 @@ export default function OAuthAuthorize() {
|
||||
const onLoginSwitchClick = async () => {
|
||||
try {
|
||||
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`)
|
||||
setOAuthPendingRedirect(returnUrl)
|
||||
if (isLoggedIn)
|
||||
await logout()
|
||||
router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`)
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: 'http://localhost:5001/console/api',
|
||||
}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
SERVER_CONSOLE_API_PREFIX: undefined,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
const getSetCookieHeaders = (headers: Headers) => {
|
||||
const getSetCookie = Reflect.get(headers, 'getSetCookie')
|
||||
|
||||
if (typeof getSetCookie === 'function') {
|
||||
const values: unknown = getSetCookie.call(headers)
|
||||
return Array.isArray(values) ? values : []
|
||||
}
|
||||
|
||||
const setCookie = headers.get('set-cookie')
|
||||
return setCookie ? [setCookie] : []
|
||||
}
|
||||
|
||||
const createRequest = (url: string, cookie?: string) => ({
|
||||
url,
|
||||
headers: new Headers(cookie ? { cookie } : undefined),
|
||||
}) as Request
|
||||
|
||||
describe('auth refresh route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should refresh cookies and redirect back to the requested path', async () => {
|
||||
const headers = new Headers()
|
||||
Object.defineProperty(headers, 'getSetCookie', {
|
||||
value: () => [
|
||||
'access_token=new-access; Path=/; HttpOnly',
|
||||
'refresh_token=new-refresh; Path=/; HttpOnly',
|
||||
],
|
||||
})
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers,
|
||||
} as Response)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps%3Fcategory%3Dworkflow',
|
||||
'refresh_token=old-refresh',
|
||||
))
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:5001/console/api/refresh-token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
)
|
||||
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers
|
||||
expect(fetchHeaders.get('cookie')).toBe('refresh_token=old-refresh')
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/apps?category=workflow')
|
||||
expect(getSetCookieHeaders(response.headers)).toEqual([
|
||||
'access_token=new-access; Path=/; HttpOnly',
|
||||
'refresh_token=new-refresh; Path=/; HttpOnly',
|
||||
])
|
||||
})
|
||||
|
||||
it('should redirect to signin when refresh token is rejected', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 })))
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=%2Fapps',
|
||||
'refresh_token=expired',
|
||||
))
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
|
||||
})
|
||||
|
||||
it('should ignore cross-origin redirect targets', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { GET } = await import('../route')
|
||||
|
||||
const response = await GET(createRequest(
|
||||
'http://localhost:3000/auth/refresh?redirect_url=https%3A%2F%2Fevil.example',
|
||||
'refresh_token=expired',
|
||||
))
|
||||
|
||||
expect(response.status).toBe(303)
|
||||
expect(response.headers.get('location')).toBe('http://localhost:3000/signin?redirect_url=%2Fapps')
|
||||
})
|
||||
})
|
||||
@ -1,113 +0,0 @@
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const REFRESH_TOKEN_PATH = '/refresh-token'
|
||||
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
|
||||
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
|
||||
|
||||
const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
|
||||
const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
|
||||
|
||||
const resolveAbsoluteUrlPrefix = (value: string) => {
|
||||
try {
|
||||
return new URL(value).toString()
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveServerConsoleApiUrl = (pathname: string, requestUrl: URL) => {
|
||||
const requestPath = withoutLeadingSlash(pathname)
|
||||
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|
||||
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|
||||
|| new URL(API_PREFIX, requestUrl.origin).toString()
|
||||
|
||||
if (!apiPrefix)
|
||||
return null
|
||||
|
||||
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
|
||||
}
|
||||
|
||||
const resolveSafeRedirectPath = (request: Request) => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectUrl = requestUrl.searchParams.get('redirect_url')
|
||||
|
||||
if (!redirectUrl)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
|
||||
try {
|
||||
const target = new URL(redirectUrl, requestUrl.origin)
|
||||
if (target.origin !== requestUrl.origin)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
if (target.pathname === AUTH_REFRESH_PATH)
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
|
||||
return `${target.pathname}${target.search}`
|
||||
}
|
||||
catch {
|
||||
return DEFAULT_REDIRECT_PATH
|
||||
}
|
||||
}
|
||||
|
||||
const getSetCookieHeaders = (headers: Headers) => {
|
||||
const getSetCookie = Reflect.get(headers, 'getSetCookie')
|
||||
|
||||
if (typeof getSetCookie === 'function') {
|
||||
const values: unknown = getSetCookie.call(headers)
|
||||
return Array.isArray(values)
|
||||
? values.filter((value): value is string => typeof value === 'string')
|
||||
: []
|
||||
}
|
||||
|
||||
const setCookie = headers.get('set-cookie')
|
||||
return setCookie ? [setCookie] : []
|
||||
}
|
||||
|
||||
const createRedirectResponse = (request: Request, pathname: string, setCookies: string[] = []) => {
|
||||
const headers = new Headers({
|
||||
'Cache-Control': 'no-store',
|
||||
'Location': new URL(pathname, request.url).toString(),
|
||||
})
|
||||
|
||||
for (const cookie of setCookies)
|
||||
headers.append('Set-Cookie', cookie)
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const createSigninRedirectResponse = (request: Request, redirectPath: string) =>
|
||||
createRedirectResponse(request, `${basePath}/signin?redirect_url=${encodeURIComponent(redirectPath)}`)
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectPath = resolveSafeRedirectPath(request)
|
||||
const refreshUrl = resolveServerConsoleApiUrl(REFRESH_TOKEN_PATH, requestUrl)
|
||||
const cookie = request.headers.get('cookie')
|
||||
|
||||
if (!refreshUrl || !cookie)
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
|
||||
try {
|
||||
const response = await fetch(refreshUrl, {
|
||||
method: 'POST',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
cookie,
|
||||
}),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
if (!response.ok)
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
|
||||
return createRedirectResponse(request, redirectPath, getSetCookieHeaders(response.headers))
|
||||
}
|
||||
catch {
|
||||
return createSigninRedirectResponse(request, redirectPath)
|
||||
}
|
||||
}
|
||||
197
web/app/components/__tests__/app-initializer.spec.tsx
Normal file
197
web/app/components/__tests__/app-initializer.spec.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { resolvePostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { AppInitializer } from '../app-initializer'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/setup-status', () => ({
|
||||
fetchSetupStatusWithCache: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/signin/utils/post-login-redirect', () => ({
|
||||
resolvePostLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
const mockFetchSetupStatusWithCache = vi.mocked(fetchSetupStatusWithCache)
|
||||
const mockResolvePostLoginRedirect = vi.mocked(resolvePostLoginRedirect)
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
describe('AppInitializer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockUsePathname.mockReturnValue('/apps')
|
||||
mockUseRouter.mockReturnValue({ replace: mockReplace } as unknown as ReturnType<typeof useRouter>)
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
mockFetchSetupStatusWithCache.mockResolvedValue({ step: 'finished' })
|
||||
mockResolvePostLoginRedirect.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('renders children after setup checks finish', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockFetchSetupStatusWithCache).toHaveBeenCalledTimes(1)
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
|
||||
it('redirects to install when setup status loading fails', async () => {
|
||||
mockFetchSetupStatusWithCache.mockRejectedValue(new Error('unauthorized'))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/install'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not persist create app attribution from the url anymore', async () => {
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.sessionStorage.getItem('create_app_external_attribution')).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks oauth registration with utm info and clears the cookie', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
}))
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(mockReplace).toHaveBeenCalledWith('/apps')
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the base registration event when the oauth utm cookie is invalid', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
{ searchParams: 'oauth_new_user=true' },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores the education verification flag in localStorage', async () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('ready')).toBeInTheDocument())
|
||||
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
|
||||
})
|
||||
|
||||
it('redirects to the resolved post-login url when one exists', async () => {
|
||||
const mockLocationReplace = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, replace: mockLocationReplace })
|
||||
mockResolvePostLoginRedirect.mockReturnValue('/explore')
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockLocationReplace).toHaveBeenCalledWith('/explore'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('redirects to signin when redirect resolution throws', async () => {
|
||||
mockResolvePostLoginRedirect.mockImplementation(() => {
|
||||
throw new Error('redirect resolution failed')
|
||||
})
|
||||
|
||||
renderWithNuqs(
|
||||
<AppInitializer>
|
||||
<div>ready</div>
|
||||
</AppInitializer>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/signin'))
|
||||
expect(screen.queryByText('ready')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,40 +0,0 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { EducationVerifyActionRecorder } from '../education-verify-action-recorder'
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
|
||||
describe('EducationVerifyActionRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams() as unknown as ReturnType<typeof useSearchParams>)
|
||||
})
|
||||
|
||||
it('should store the education verification flag when the callback action is present', async () => {
|
||||
mockUseSearchParams.mockReturnValue(
|
||||
new URLSearchParams(`action=${EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION}`) as unknown as ReturnType<typeof useSearchParams>,
|
||||
)
|
||||
|
||||
render(<EducationVerifyActionRecorder />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBe('yes')
|
||||
})
|
||||
})
|
||||
|
||||
it('should leave localStorage unchanged for unrelated routes', () => {
|
||||
render(<EducationVerifyActionRecorder />)
|
||||
|
||||
expect(window.localStorage.getItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -1,106 +0,0 @@
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
sendGAEvent: (...args: unknown[]) => mockSendGAEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
|
||||
const setSearchParams = (searchParams = '') => {
|
||||
mockUseSearchParams.mockReturnValue(new URLSearchParams(searchParams) as unknown as ReturnType<typeof useSearchParams>)
|
||||
window.history.replaceState(null, '', `/signin${searchParams ? `?${searchParams}` : ''}`)
|
||||
}
|
||||
|
||||
describe('OAuthRegistrationAnalytics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Cookies.remove('utm_info')
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setSearchParams()
|
||||
})
|
||||
|
||||
it('should track oauth registration with utm info and clear the query flag', async () => {
|
||||
Cookies.set('utm_info', JSON.stringify({
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
}))
|
||||
|
||||
setSearchParams('oauth_new_user=true&source=signin')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin?source=signin')
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to the base registration event when the utm cookie is invalid', async () => {
|
||||
Cookies.set('utm_info', '{invalid-json')
|
||||
|
||||
setSearchParams('oauth_new_user=true')
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
})
|
||||
expect(console.error).toHaveBeenCalled()
|
||||
expect(Cookies.get('utm_info')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear a false oauth registration query flag without tracking', async () => {
|
||||
setSearchParams('oauth_new_user=false')
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
103
web/app/components/app-initializer.tsx
Normal file
103
web/app/components/app-initializer.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import Cookies from 'js-cookie'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import RootLoading from '@/app/loading'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
|
||||
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
type AppInitializerProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const AppInitializer = ({
|
||||
children,
|
||||
}: AppInitializerProps) => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
// Tokens are now stored in cookies, no need to check localStorage
|
||||
const pathname = usePathname()
|
||||
const [init, setInit] = useState(false)
|
||||
const [oauthNewUser] = useQueryState(
|
||||
'oauth_new_user',
|
||||
parseAsBoolean.withOptions({ history: 'replace' }),
|
||||
)
|
||||
const isSetupFinished = useCallback(async () => {
|
||||
try {
|
||||
const setUpStatus = await fetchSetupStatusWithCache()
|
||||
return setUpStatus.step === 'finished'
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const action = searchParams.get('action')
|
||||
|
||||
if (oauthNewUser) {
|
||||
let utmInfo = null
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
if (utmInfoStr) {
|
||||
try {
|
||||
utmInfo = JSON.parse(utmInfoStr)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse utm_info cookie:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Track registration event with UTM params
|
||||
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
Cookies.remove('utm_info')
|
||||
}
|
||||
|
||||
if (oauthNewUser !== null)
|
||||
router.replace(pathname)
|
||||
|
||||
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
|
||||
try {
|
||||
const isFinished = await isSetupFinished()
|
||||
if (!isFinished) {
|
||||
router.replace('/install')
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUrl = resolvePostLoginRedirect(searchParams)
|
||||
if (redirectUrl) {
|
||||
location.replace(redirectUrl)
|
||||
return
|
||||
}
|
||||
|
||||
setInit(true)
|
||||
}
|
||||
catch {
|
||||
router.replace('/signin')
|
||||
}
|
||||
})()
|
||||
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
|
||||
|
||||
return init ? children : <RootLoading />
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
|
||||
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
|
||||
} from '@/app/education-apply/constants'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
|
||||
export function EducationVerifyActionRecorder() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('action') === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
|
||||
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
|
||||
}, [searchParams])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -277,6 +277,7 @@ describe('AccountDropdown', () => {
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('setup_status')
|
||||
expect(mockPush).toHaveBeenCalledWith('/signin')
|
||||
})
|
||||
})
|
||||
|
||||
@ -121,6 +121,7 @@ export default function AppSelector() {
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
resetUser()
|
||||
localStorage.removeItem('setup_status')
|
||||
// Tokens are now stored in cookies and cleared by backend
|
||||
|
||||
// To avoid use other account's education notice info
|
||||
|
||||
@ -4,7 +4,6 @@ import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { useLocalStorageBoolean } from '@/utils/local-storage'
|
||||
import s from './index.module.css'
|
||||
|
||||
type HeaderWrapperProps = {
|
||||
@ -19,14 +18,13 @@ const HeaderWrapper = ({
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const storedHideHeader = useLocalStorageBoolean('workflow-canvas-maximize')
|
||||
const [eventHideHeader, setEventHideHeader] = useState<boolean | null>(null)
|
||||
const hideHeader = eventHideHeader ?? storedHideHeader
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setEventHideHeader(v.payload)
|
||||
setHideHeader(v.payload)
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@ -3,22 +3,19 @@ import { useTranslation } from 'react-i18next'
|
||||
import { X } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import { setLocalStorageItem, useLocalStorageItem } from '@/utils/local-storage'
|
||||
|
||||
const MaintenanceNotice = () => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLanguage()
|
||||
|
||||
const hiddenNotice = useLocalStorageItem('hide-maintenance-notice') === '1'
|
||||
const [closedInSession, setClosedInSession] = useState(false)
|
||||
const showNotice = !hiddenNotice && !closedInSession
|
||||
const [showNotice, setShowNotice] = useState(() => localStorage.getItem('hide-maintenance-notice') !== '1')
|
||||
const handleJumpNotice = () => {
|
||||
window.open(NOTICE_I18N.href, '_blank')
|
||||
}
|
||||
|
||||
const handleCloseNotice = () => {
|
||||
setLocalStorageItem('hide-maintenance-notice', '1')
|
||||
setClosedInSession(true)
|
||||
localStorage.setItem('hide-maintenance-notice', '1')
|
||||
setShowNotice(false)
|
||||
}
|
||||
|
||||
const titleByLocale: { [key: string]: string } = NOTICE_I18N.title
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const removeOAuthNewUserParam = () => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete(OAUTH_NEW_USER_PARAM)
|
||||
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}
|
||||
|
||||
export function OAuthRegistrationAnalytics() {
|
||||
const searchParams = useSearchParams()
|
||||
const oauthNewUserParam = searchParams.get(OAUTH_NEW_USER_PARAM)
|
||||
const handledParamRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthNewUserParam === null || handledParamRef.current === oauthNewUserParam)
|
||||
return
|
||||
|
||||
handledParamRef.current = oauthNewUserParam
|
||||
const oauthNewUser = oauthNewUserParam === 'true'
|
||||
if (!oauthNewUser) {
|
||||
removeOAuthNewUserParam()
|
||||
return
|
||||
}
|
||||
|
||||
let utmInfo: Record<string, unknown> | null = null
|
||||
const utmInfoStr = Cookies.get('utm_info')
|
||||
if (utmInfoStr) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(utmInfoStr)
|
||||
if (isRecord(parsed))
|
||||
utmInfo = parsed
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse utm_info cookie:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
|
||||
|
||||
trackEvent(eventName, {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
sendGAEvent(eventName, {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
Cookies.remove('utm_info')
|
||||
removeOAuthNewUserParam()
|
||||
}, [oauthNewUserParam])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import { getLocalStorageBoolean, getLocalStorageNumber } from '@/utils/local-storage'
|
||||
|
||||
export type LayoutSliceShape = {
|
||||
workflowCanvasWidth?: number
|
||||
@ -35,10 +34,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
|
||||
rightPanelWidth: undefined,
|
||||
setRightPanelWidth: width => set(state =>
|
||||
state.rightPanelWidth === width ? state : ({ rightPanelWidth: width })),
|
||||
nodePanelWidth: getLocalStorageNumber('workflow-node-panel-width', 400),
|
||||
nodePanelWidth: localStorage.getItem('workflow-node-panel-width') ? Number.parseFloat(localStorage.getItem('workflow-node-panel-width')!) : 400,
|
||||
setNodePanelWidth: width => set(state =>
|
||||
state.nodePanelWidth === width ? state : ({ nodePanelWidth: width })),
|
||||
previewPanelWidth: getLocalStorageNumber('debug-and-preview-panel-width', 400),
|
||||
previewPanelWidth: localStorage.getItem('debug-and-preview-panel-width') ? Number.parseFloat(localStorage.getItem('debug-and-preview-panel-width')!) : 400,
|
||||
setPreviewPanelWidth: width => set(state =>
|
||||
state.previewPanelWidth === width ? state : ({ previewPanelWidth: width })),
|
||||
otherPanelWidth: 400,
|
||||
@ -50,10 +49,10 @@ export const createLayoutSlice: StateCreator<LayoutSliceShape> = set => ({
|
||||
bottomPanelHeight: 324,
|
||||
setBottomPanelHeight: height => set(state =>
|
||||
state.bottomPanelHeight === height ? state : ({ bottomPanelHeight: height })),
|
||||
variableInspectPanelHeight: getLocalStorageNumber('workflow-variable-inpsect-panel-height', 320),
|
||||
variableInspectPanelHeight: localStorage.getItem('workflow-variable-inpsect-panel-height') ? Number.parseFloat(localStorage.getItem('workflow-variable-inpsect-panel-height')!) : 320,
|
||||
setVariableInspectPanelHeight: height => set(state =>
|
||||
state.variableInspectPanelHeight === height ? state : ({ variableInspectPanelHeight: height })),
|
||||
maximizeCanvas: getLocalStorageBoolean('workflow-canvas-maximize'),
|
||||
maximizeCanvas: localStorage.getItem('workflow-canvas-maximize') === 'true',
|
||||
setMaximizeCanvas: maximize => set(state =>
|
||||
state.maximizeCanvas === maximize ? state : ({ maximizeCanvas: maximize })),
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user