chatenv 0.2.5__tar.gz → 0.2.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chatenv-0.2.5/src/chatenv.egg-info → chatenv-0.2.7}/PKG-INFO +14 -2
- {chatenv-0.2.5 → chatenv-0.2.7}/README.md +13 -1
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/__init__.py +3 -1
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/cli.py +48 -15
- chatenv-0.2.7/src/chatenv/token_refreshers.py +146 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/tokens.py +21 -1
- {chatenv-0.2.5 → chatenv-0.2.7/src/chatenv.egg-info}/PKG-INFO +14 -2
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/SOURCES.txt +1 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/tests/test_chatenv_core.py +151 -8
- {chatenv-0.2.5 → chatenv-0.2.7}/tests/test_version.py +3 -2
- {chatenv-0.2.5 → chatenv-0.2.7}/LICENSE +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/pyproject.toml +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/setup.cfg +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/configs.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/discovery.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/fields.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/paste.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/paths.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/presets/__init__.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/registry.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/source_chain.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/store.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv/utils.py +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/dependency_links.txt +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/entry_points.txt +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/requires.txt +0 -0
- {chatenv-0.2.5 → chatenv-0.2.7}/src/chatenv.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatenv
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: ChatArch typed environment profile manager
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -68,7 +68,8 @@ pip install -e .[dev] # 仓库开发
|
|
|
68
68
|
|
|
69
69
|
```text
|
|
70
70
|
CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
|
|
71
|
-
$CHATARCH_HOME/envs/
|
|
71
|
+
$CHATARCH_HOME/envs/ # stable typed env/profile files
|
|
72
|
+
$CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
实际布局由业务项目注册的 schema 决定,例如:
|
|
@@ -122,6 +123,17 @@ export CHATARCH_AUTO_PROMPT=false
|
|
|
122
123
|
|
|
123
124
|
更多用法见 `docs/cli.md`。
|
|
124
125
|
|
|
126
|
+
## Runtime tokens
|
|
127
|
+
|
|
128
|
+
ChatEnv 区分 stable env/profile 和动态 runtime token:
|
|
129
|
+
|
|
130
|
+
- `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
|
|
131
|
+
- `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
|
|
132
|
+
- `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
|
|
133
|
+
- 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
|
|
134
|
+
|
|
135
|
+
ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
|
|
136
|
+
|
|
125
137
|
## Paste
|
|
126
138
|
|
|
127
139
|
`paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
|
|
@@ -38,7 +38,8 @@ pip install -e .[dev] # 仓库开发
|
|
|
38
38
|
|
|
39
39
|
```text
|
|
40
40
|
CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
|
|
41
|
-
$CHATARCH_HOME/envs/
|
|
41
|
+
$CHATARCH_HOME/envs/ # stable typed env/profile files
|
|
42
|
+
$CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
|
|
42
43
|
```
|
|
43
44
|
|
|
44
45
|
实际布局由业务项目注册的 schema 决定,例如:
|
|
@@ -92,6 +93,17 @@ export CHATARCH_AUTO_PROMPT=false
|
|
|
92
93
|
|
|
93
94
|
更多用法见 `docs/cli.md`。
|
|
94
95
|
|
|
96
|
+
## Runtime tokens
|
|
97
|
+
|
|
98
|
+
ChatEnv 区分 stable env/profile 和动态 runtime token:
|
|
99
|
+
|
|
100
|
+
- `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
|
|
101
|
+
- `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
|
|
102
|
+
- `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
|
|
103
|
+
- 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
|
|
104
|
+
|
|
105
|
+
ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
|
|
106
|
+
|
|
95
107
|
## Paste
|
|
96
108
|
|
|
97
109
|
`paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from .fields import BaseEnvConfig, EnvField
|
|
4
4
|
from .paths import ChatArchPaths, get_paths
|
|
5
5
|
from .store import EnvStore
|
|
6
|
+
from .token_refreshers import TokenRefreshResult
|
|
6
7
|
from .tokens import TokenStore
|
|
7
8
|
from .configs import FeishuConfig, OpenAIConfig
|
|
8
9
|
from .discovery import get_provider_configs, get_provider_errors, load_config_providers
|
|
@@ -14,6 +15,7 @@ __all__ = [
|
|
|
14
15
|
"EnvStore",
|
|
15
16
|
"FeishuConfig",
|
|
16
17
|
"OpenAIConfig",
|
|
18
|
+
"TokenRefreshResult",
|
|
17
19
|
"TokenStore",
|
|
18
20
|
"get_provider_configs",
|
|
19
21
|
"get_provider_errors",
|
|
@@ -22,4 +24,4 @@ __all__ = [
|
|
|
22
24
|
"__version__",
|
|
23
25
|
]
|
|
24
26
|
|
|
25
|
-
__version__ = "0.2.
|
|
27
|
+
__version__ = "0.2.7"
|
|
@@ -27,6 +27,7 @@ from .paste import iter_fields_for_values, parse_pasted_env_text
|
|
|
27
27
|
from .paths import get_paths
|
|
28
28
|
from .registry import resolve_config_types
|
|
29
29
|
from .store import EnvStore
|
|
30
|
+
from .token_refreshers import refresh_token as refresh_runtime_token
|
|
30
31
|
from .tokens import TokenStore
|
|
31
32
|
from .utils import mask_secret
|
|
32
33
|
|
|
@@ -129,7 +130,7 @@ class OrderedGroup(click.Group):
|
|
|
129
130
|
|
|
130
131
|
|
|
131
132
|
class TokenCommandGroup(OrderedGroup):
|
|
132
|
-
command_order = ["status", "refresh", "list", "clear"]
|
|
133
|
+
command_order = ["status", "refresh", "import", "list", "clear"]
|
|
133
134
|
|
|
134
135
|
|
|
135
136
|
def _format_metavar(name: str) -> str:
|
|
@@ -625,7 +626,7 @@ def _render_token_status(payload: dict[str, object]) -> None:
|
|
|
625
626
|
|
|
626
627
|
def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str, object]:
|
|
627
628
|
if read_stdin == bool(value_file):
|
|
628
|
-
raise click.ClickException("token
|
|
629
|
+
raise click.ClickException("token import requires exactly one of --stdin or --file.")
|
|
629
630
|
text = sys.stdin.read() if read_stdin else value_file.read_text(encoding="utf-8") # type: ignore[union-attr]
|
|
630
631
|
try:
|
|
631
632
|
payload = json.loads(text)
|
|
@@ -639,6 +640,29 @@ def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str
|
|
|
639
640
|
@token_group.command(name="refresh")
|
|
640
641
|
@click.argument("service")
|
|
641
642
|
@click.argument("profile", required=False, default="default")
|
|
643
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
644
|
+
@click.pass_context
|
|
645
|
+
def token_refresh(ctx: click.Context, service: str, profile: str, output_format: str):
|
|
646
|
+
"""Refresh SERVICE/PROFILE through a registered service refresh provider."""
|
|
647
|
+
try:
|
|
648
|
+
payload = refresh_runtime_token(
|
|
649
|
+
service,
|
|
650
|
+
profile,
|
|
651
|
+
home=ctx.obj["paths"].home_dir,
|
|
652
|
+
env_store=_store(ctx),
|
|
653
|
+
token_store=_token_store(ctx),
|
|
654
|
+
)
|
|
655
|
+
except ValueError as exc:
|
|
656
|
+
raise click.ClickException(str(exc)) from exc
|
|
657
|
+
if output_format == "json":
|
|
658
|
+
_echo_json(payload)
|
|
659
|
+
else:
|
|
660
|
+
_render_token_status(payload)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@token_group.command(name="import")
|
|
664
|
+
@click.argument("service")
|
|
665
|
+
@click.argument("profile", required=False, default="default")
|
|
642
666
|
@click.option("--stdin", "read_stdin", is_flag=True, help="Read token JSON object from stdin.")
|
|
643
667
|
@click.option("--file", "value_file", type=click.Path(dir_okay=False, path_type=Path), help="Read token JSON object from a file.")
|
|
644
668
|
@click.option("--token-type", default="runtime", show_default=True, help="Caller-defined token/session type.")
|
|
@@ -646,7 +670,7 @@ def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str
|
|
|
646
670
|
@click.option("--expires-at", default="", help="Optional caller-provided expiry timestamp.")
|
|
647
671
|
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
648
672
|
@click.pass_context
|
|
649
|
-
def
|
|
673
|
+
def token_import(
|
|
650
674
|
ctx: click.Context,
|
|
651
675
|
service: str,
|
|
652
676
|
profile: str,
|
|
@@ -657,17 +681,20 @@ def token_refresh(
|
|
|
657
681
|
expires_at: str,
|
|
658
682
|
output_format: str,
|
|
659
683
|
):
|
|
660
|
-
"""
|
|
684
|
+
"""Explicitly import externally refreshed runtime token JSON."""
|
|
661
685
|
values = _read_token_values(read_stdin=read_stdin, value_file=value_file)
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
686
|
+
try:
|
|
687
|
+
payload = _token_store(ctx).write(
|
|
688
|
+
service,
|
|
689
|
+
profile,
|
|
690
|
+
values=values,
|
|
691
|
+
token_type=token_type,
|
|
692
|
+
summary=_parse_summary(summary),
|
|
693
|
+
expires_at=expires_at,
|
|
694
|
+
source="import",
|
|
695
|
+
)
|
|
696
|
+
except ValueError as exc:
|
|
697
|
+
raise click.ClickException(str(exc)) from exc
|
|
671
698
|
if output_format == "json":
|
|
672
699
|
_echo_json(payload)
|
|
673
700
|
else:
|
|
@@ -681,7 +708,10 @@ def token_refresh(
|
|
|
681
708
|
@click.pass_context
|
|
682
709
|
def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
|
|
683
710
|
"""Show safe runtime token metadata for SERVICE/PROFILE."""
|
|
684
|
-
|
|
711
|
+
try:
|
|
712
|
+
payload = _token_store(ctx).status(service, profile)
|
|
713
|
+
except ValueError as exc:
|
|
714
|
+
raise click.ClickException(str(exc)) from exc
|
|
685
715
|
if output_format == "json":
|
|
686
716
|
_echo_json(payload)
|
|
687
717
|
else:
|
|
@@ -721,7 +751,10 @@ def token_list(ctx: click.Context, service: str | None, output_format: str):
|
|
|
721
751
|
@click.pass_context
|
|
722
752
|
def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
|
|
723
753
|
"""Clear a generic runtime token file for SERVICE/PROFILE."""
|
|
724
|
-
|
|
754
|
+
try:
|
|
755
|
+
payload = _token_store(ctx).clear(service, profile, execute=execute)
|
|
756
|
+
except ValueError as exc:
|
|
757
|
+
raise click.ClickException(str(exc)) from exc
|
|
725
758
|
if output_format == "json":
|
|
726
759
|
_echo_json(payload)
|
|
727
760
|
else:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Service-owned runtime token refresh hooks.
|
|
2
|
+
|
|
3
|
+
ChatEnv owns token-store persistence and safe metadata rendering. Service
|
|
4
|
+
packages own authentication semantics. A service can register a refresh
|
|
5
|
+
provider through the ``chatenv.token_refreshers`` entry-point group; ChatEnv
|
|
6
|
+
then invokes that provider for ``chatenv token refresh SERVICE PROFILE`` and
|
|
7
|
+
writes the returned opaque runtime values into ``tokens/<Service>/<profile>.json``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib.metadata import EntryPoint, entry_points
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Iterable, Mapping
|
|
16
|
+
|
|
17
|
+
from .paths import get_paths
|
|
18
|
+
from .store import EnvStore
|
|
19
|
+
from .tokens import DEFAULT_TOKEN_TYPE, TokenStore, normalize_service_name, normalize_token_profile
|
|
20
|
+
|
|
21
|
+
ENTRY_POINT_GROUP = "chatenv.token_refreshers"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TokenRefreshResult:
|
|
26
|
+
"""Opaque values plus safe metadata returned by a service refresh hook."""
|
|
27
|
+
|
|
28
|
+
values: Mapping[str, Any]
|
|
29
|
+
token_type: str = DEFAULT_TOKEN_TYPE
|
|
30
|
+
summary: Mapping[str, Any] | None = None
|
|
31
|
+
expires_at: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_token_refreshers: dict[str, Callable[..., Any]] = {}
|
|
35
|
+
_loaded = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _iter_refresh_entry_points() -> Iterable[EntryPoint]:
|
|
39
|
+
eps = entry_points()
|
|
40
|
+
if hasattr(eps, "select"):
|
|
41
|
+
return eps.select(group=ENTRY_POINT_GROUP)
|
|
42
|
+
return eps.get(ENTRY_POINT_GROUP, [])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def clear_token_refreshers() -> None:
|
|
46
|
+
"""Clear the refresh-provider cache; intended for tests and reloads."""
|
|
47
|
+
|
|
48
|
+
global _loaded
|
|
49
|
+
_loaded = False
|
|
50
|
+
_token_refreshers.clear()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _provider_callable(obj: Any) -> Callable[..., Any]:
|
|
54
|
+
if callable(obj):
|
|
55
|
+
return obj
|
|
56
|
+
for attr in ("refresh_token", "refresh"):
|
|
57
|
+
candidate = getattr(obj, attr, None)
|
|
58
|
+
if callable(candidate):
|
|
59
|
+
return candidate
|
|
60
|
+
raise TypeError("token refresh provider must be callable or expose refresh_token()/refresh()")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_token_refreshers(*, force: bool = False) -> dict[str, Callable[..., Any]]:
|
|
64
|
+
"""Load service refresh hooks registered through entry points."""
|
|
65
|
+
|
|
66
|
+
global _loaded
|
|
67
|
+
if _loaded and not force:
|
|
68
|
+
return dict(_token_refreshers)
|
|
69
|
+
if force:
|
|
70
|
+
_token_refreshers.clear()
|
|
71
|
+
_loaded = True
|
|
72
|
+
for ep in _iter_refresh_entry_points():
|
|
73
|
+
service_name = normalize_service_name(ep.name)
|
|
74
|
+
provider = _provider_callable(ep.load())
|
|
75
|
+
_token_refreshers[service_name.lower()] = provider
|
|
76
|
+
return dict(_token_refreshers)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_token_refresher(service: str, *, force: bool = False) -> Callable[..., Any] | None:
|
|
80
|
+
service_name = normalize_service_name(service)
|
|
81
|
+
return load_token_refreshers(force=force).get(service_name.lower())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _coerce_refresh_result(result: Any) -> TokenRefreshResult:
|
|
85
|
+
if isinstance(result, TokenRefreshResult):
|
|
86
|
+
return result
|
|
87
|
+
if isinstance(result, Mapping):
|
|
88
|
+
values = result.get("values")
|
|
89
|
+
if not isinstance(values, Mapping):
|
|
90
|
+
raise ValueError("token refresh provider result must include a non-empty values object")
|
|
91
|
+
return TokenRefreshResult(
|
|
92
|
+
values=values,
|
|
93
|
+
token_type=str(result.get("token_type") or DEFAULT_TOKEN_TYPE),
|
|
94
|
+
summary=result.get("summary") if isinstance(result.get("summary"), Mapping) else None,
|
|
95
|
+
expires_at=str(result.get("expires_at") or ""),
|
|
96
|
+
)
|
|
97
|
+
raise ValueError("token refresh provider must return TokenRefreshResult or a mapping")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def refresh_token(
|
|
101
|
+
service: str,
|
|
102
|
+
profile: str | None = None,
|
|
103
|
+
*,
|
|
104
|
+
home: str | Path | None = None,
|
|
105
|
+
env_store: EnvStore | None = None,
|
|
106
|
+
token_store: TokenStore | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Invoke the registered service refresh hook and persist its token values."""
|
|
109
|
+
|
|
110
|
+
service_name = normalize_service_name(service)
|
|
111
|
+
profile_name = normalize_token_profile(profile)
|
|
112
|
+
provider = get_token_refresher(service_name)
|
|
113
|
+
if provider is None:
|
|
114
|
+
raise ValueError(f"No token refresh provider registered for {service_name}")
|
|
115
|
+
|
|
116
|
+
paths = get_paths(home)
|
|
117
|
+
env_store = env_store or EnvStore(paths.envs_dir)
|
|
118
|
+
token_store = token_store or TokenStore(tokens_dir=paths.tokens_dir)
|
|
119
|
+
result = _coerce_refresh_result(
|
|
120
|
+
provider(
|
|
121
|
+
service=service_name,
|
|
122
|
+
profile=profile_name,
|
|
123
|
+
home=paths.home_dir,
|
|
124
|
+
env_store=env_store,
|
|
125
|
+
token_store=token_store,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return token_store.write(
|
|
129
|
+
service_name,
|
|
130
|
+
profile_name,
|
|
131
|
+
values=dict(result.values),
|
|
132
|
+
token_type=result.token_type or DEFAULT_TOKEN_TYPE,
|
|
133
|
+
summary=dict(result.summary or {}),
|
|
134
|
+
expires_at=result.expires_at or "",
|
|
135
|
+
source="refresh",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = [
|
|
140
|
+
"ENTRY_POINT_GROUP",
|
|
141
|
+
"TokenRefreshResult",
|
|
142
|
+
"clear_token_refreshers",
|
|
143
|
+
"get_token_refresher",
|
|
144
|
+
"load_token_refreshers",
|
|
145
|
+
"refresh_token",
|
|
146
|
+
]
|
|
@@ -36,12 +36,32 @@ def _safe_segment(value: str, *, default: str) -> str:
|
|
|
36
36
|
return cleaned or default
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _strict_token_profile_segment(value: str | None) -> str:
|
|
40
|
+
if value is None:
|
|
41
|
+
return DEFAULT_PROFILE
|
|
42
|
+
raw = str(value)
|
|
43
|
+
stripped = raw.strip()
|
|
44
|
+
if not stripped:
|
|
45
|
+
raise ValueError("token profile must not be empty")
|
|
46
|
+
if stripped != raw:
|
|
47
|
+
raise ValueError("token profile must not contain leading or trailing whitespace")
|
|
48
|
+
if stripped in {".", ".."}:
|
|
49
|
+
raise ValueError("token profile must not be '.' or '..'")
|
|
50
|
+
if "/" in stripped or "\\" in stripped:
|
|
51
|
+
raise ValueError("token profile must be a single path segment")
|
|
52
|
+
if re.search(r"[^A-Za-z0-9_.-]", stripped):
|
|
53
|
+
raise ValueError("token profile may contain only letters, numbers, dot, underscore, and hyphen")
|
|
54
|
+
if stripped.strip(".-") != stripped:
|
|
55
|
+
raise ValueError("token profile must not start or end with dot or hyphen")
|
|
56
|
+
return stripped
|
|
57
|
+
|
|
58
|
+
|
|
39
59
|
def normalize_service_name(value: str) -> str:
|
|
40
60
|
return _safe_segment(value, default="Service")
|
|
41
61
|
|
|
42
62
|
|
|
43
63
|
def normalize_token_profile(value: str | None) -> str:
|
|
44
|
-
return
|
|
64
|
+
return _strict_token_profile_segment(value)
|
|
45
65
|
|
|
46
66
|
|
|
47
67
|
def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chatenv
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: ChatArch typed environment profile manager
|
|
5
5
|
Author-email: rexwzh <1073853456@qq.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -68,7 +68,8 @@ pip install -e .[dev] # 仓库开发
|
|
|
68
68
|
|
|
69
69
|
```text
|
|
70
70
|
CHATARCH_HOME=${CHATARCH_HOME:-~/.chatarch}
|
|
71
|
-
$CHATARCH_HOME/envs/
|
|
71
|
+
$CHATARCH_HOME/envs/ # stable typed env/profile files
|
|
72
|
+
$CHATARCH_HOME/tokens/ # generated runtime tokens/sessions, parallel to env profiles
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
实际布局由业务项目注册的 schema 决定,例如:
|
|
@@ -122,6 +123,17 @@ export CHATARCH_AUTO_PROMPT=false
|
|
|
122
123
|
|
|
123
124
|
更多用法见 `docs/cli.md`。
|
|
124
125
|
|
|
126
|
+
## Runtime tokens
|
|
127
|
+
|
|
128
|
+
ChatEnv 区分 stable env/profile 和动态 runtime token:
|
|
129
|
+
|
|
130
|
+
- `envs/<Service>/<profile>.env` 保存可长期维护的稳定配置,例如 base URL、账号、API key 或 refresh/login 所需输入;
|
|
131
|
+
- `tokens/<Service>/<profile>.json` 保存由登录或刷新流程生成的 access token、session、cookie/CSRF 等运行态;
|
|
132
|
+
- `chatenv token refresh SERVICE PROFILE` 会调用服务包通过 `chatenv.token_refreshers` 注册的刷新器,自动生成并更新 token-store;
|
|
133
|
+
- 手工 JSON 写入只保留为显式 `chatenv token import ... --stdin/--file`,用于迁移、调试或外部刷新器交接,不应作为日常维护入口。
|
|
134
|
+
|
|
135
|
+
ChatEnv 只负责路径、profile 校验、原子写入和 safe status/list/clear;具体登录、OAuth refresh、cookie 刷新等业务语义仍由 leaf package 负责。
|
|
136
|
+
|
|
125
137
|
## Paste
|
|
126
138
|
|
|
127
139
|
`paste` 是跨设备复制密钥的核心入口。输入不要求是严格 dotenv 文件,会从终端日志、shell prompt、复制文本里提取已注册 key;同一行里用空格分隔的多个 `KEY='VALUE'` 片段也会被逐个识别。
|
|
@@ -2,6 +2,7 @@ import json
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
from types import SimpleNamespace
|
|
4
4
|
|
|
5
|
+
import pytest
|
|
5
6
|
from click.testing import CliRunner
|
|
6
7
|
import chatenv.discovery as discovery_module
|
|
7
8
|
import chatenv.cli as cli_module
|
|
@@ -294,6 +295,147 @@ def test_token_store_list_and_clear_are_generic(tmp_path):
|
|
|
294
295
|
assert store.status("PyPI", "RexWzh")["token_present"] is False
|
|
295
296
|
|
|
296
297
|
|
|
298
|
+
def test_token_store_rejects_aliasing_profile_segments(tmp_path):
|
|
299
|
+
from chatenv.tokens import TokenStore, normalize_token_profile
|
|
300
|
+
|
|
301
|
+
store = TokenStore(home=tmp_path / "arch")
|
|
302
|
+
store.write("PyPI", "RexWzh", values={"session": "opaque-value"}, token_type="web_session")
|
|
303
|
+
|
|
304
|
+
invalid_profiles = [
|
|
305
|
+
"",
|
|
306
|
+
" ",
|
|
307
|
+
".",
|
|
308
|
+
"..",
|
|
309
|
+
"../RexWzh",
|
|
310
|
+
"RexWzh/other",
|
|
311
|
+
"RexWzh\\other",
|
|
312
|
+
" RexWzh",
|
|
313
|
+
"RexWzh ",
|
|
314
|
+
".RexWzh",
|
|
315
|
+
"RexWzh.",
|
|
316
|
+
"Rex Wzh",
|
|
317
|
+
]
|
|
318
|
+
for profile in invalid_profiles:
|
|
319
|
+
with pytest.raises(ValueError):
|
|
320
|
+
normalize_token_profile(profile)
|
|
321
|
+
with pytest.raises(ValueError):
|
|
322
|
+
store.status("PyPI", profile)
|
|
323
|
+
|
|
324
|
+
assert store.status("PyPI", "RexWzh")["token_present"] is True
|
|
325
|
+
assert store.status("PyPI", None)["profile"] == "default"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def test_token_cli_reports_invalid_profile_without_aliasing(tmp_path):
|
|
329
|
+
runner = CliRunner()
|
|
330
|
+
result = runner.invoke(
|
|
331
|
+
cli,
|
|
332
|
+
["--home", str(tmp_path / "arch"), "token", "status", "PyPI", "../RexWzh", "--format", "json"],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
assert result.exit_code != 0
|
|
336
|
+
assert "single path segment" in result.output
|
|
337
|
+
assert "Traceback" not in result.output
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_token_refresh_uses_registered_provider_not_manual_json(monkeypatch, tmp_path):
|
|
341
|
+
from chatenv import token_refreshers as refresh_module
|
|
342
|
+
from chatenv.token_refreshers import TokenRefreshResult
|
|
343
|
+
from chatenv.tokens import TokenStore
|
|
344
|
+
|
|
345
|
+
runner = CliRunner()
|
|
346
|
+
home = tmp_path / "arch"
|
|
347
|
+
calls = []
|
|
348
|
+
|
|
349
|
+
def refresh(**kwargs):
|
|
350
|
+
calls.append(kwargs)
|
|
351
|
+
assert kwargs["service"] == "PyPI"
|
|
352
|
+
assert kwargs["profile"] == "RexWzh"
|
|
353
|
+
assert kwargs["home"] == home
|
|
354
|
+
return TokenRefreshResult(
|
|
355
|
+
values={"session": "opaque-value"},
|
|
356
|
+
token_type="web_session",
|
|
357
|
+
summary={"username": "operator"},
|
|
358
|
+
expires_at="2026-08-11T23:00:00Z",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
class FakeEntryPoint:
|
|
362
|
+
name = "PyPI"
|
|
363
|
+
value = "tests.fake:refresh"
|
|
364
|
+
|
|
365
|
+
def load(self):
|
|
366
|
+
return refresh
|
|
367
|
+
|
|
368
|
+
monkeypatch.setattr(refresh_module, "_iter_refresh_entry_points", lambda: [FakeEntryPoint()])
|
|
369
|
+
refresh_module.clear_token_refreshers()
|
|
370
|
+
|
|
371
|
+
result = runner.invoke(
|
|
372
|
+
cli,
|
|
373
|
+
["--home", str(home), "token", "refresh", "PyPI", "RexWzh", "--format", "json"],
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
assert result.exit_code == 0, result.output
|
|
377
|
+
assert len(calls) == 1
|
|
378
|
+
payload = json.loads(result.output)
|
|
379
|
+
assert payload["service"] == "PyPI"
|
|
380
|
+
assert payload["profile"] == "RexWzh"
|
|
381
|
+
assert payload["token_type"] == "web_session"
|
|
382
|
+
assert payload["source"] == "refresh"
|
|
383
|
+
assert payload["summary"] == {"username": "operator"}
|
|
384
|
+
assert "opaque-value" not in result.output
|
|
385
|
+
assert TokenStore(home=home).read("PyPI", "RexWzh")["values"] == {"session": "opaque-value"}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_token_refresh_without_provider_does_not_accept_manual_payload(monkeypatch, tmp_path):
|
|
389
|
+
from chatenv import token_refreshers as refresh_module
|
|
390
|
+
|
|
391
|
+
runner = CliRunner()
|
|
392
|
+
home = tmp_path / "arch"
|
|
393
|
+
monkeypatch.setattr(refresh_module, "_iter_refresh_entry_points", lambda: [])
|
|
394
|
+
refresh_module.clear_token_refreshers()
|
|
395
|
+
|
|
396
|
+
result = runner.invoke(
|
|
397
|
+
cli,
|
|
398
|
+
["--home", str(home), "token", "refresh", "PyPI", "RexWzh", "--format", "json"],
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
assert result.exit_code != 0
|
|
402
|
+
assert "No token refresh provider registered for PyPI" in result.output
|
|
403
|
+
assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_token_import_is_explicit_manual_json_path(tmp_path):
|
|
407
|
+
from chatenv.tokens import TokenStore
|
|
408
|
+
|
|
409
|
+
runner = CliRunner()
|
|
410
|
+
home = tmp_path / "arch"
|
|
411
|
+
result = runner.invoke(
|
|
412
|
+
cli,
|
|
413
|
+
[
|
|
414
|
+
"--home",
|
|
415
|
+
str(home),
|
|
416
|
+
"token",
|
|
417
|
+
"import",
|
|
418
|
+
"PyPI",
|
|
419
|
+
"RexWzh",
|
|
420
|
+
"--stdin",
|
|
421
|
+
"--token-type",
|
|
422
|
+
"web_session",
|
|
423
|
+
"--summary",
|
|
424
|
+
"username=operator",
|
|
425
|
+
"--format",
|
|
426
|
+
"json",
|
|
427
|
+
],
|
|
428
|
+
input=json.dumps({"session": "opaque-value"}),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
assert result.exit_code == 0, result.output
|
|
432
|
+
payload = json.loads(result.output)
|
|
433
|
+
assert payload["source"] == "import"
|
|
434
|
+
assert payload["summary"] == {"username": "operator"}
|
|
435
|
+
assert "opaque-value" not in result.output
|
|
436
|
+
assert TokenStore(home=home).read("PyPI", "RexWzh")["values"] == {"session": "opaque-value"}
|
|
437
|
+
|
|
438
|
+
|
|
297
439
|
def test_store_profile_roundtrip(tmp_path):
|
|
298
440
|
store = EnvStore(tmp_path / "envs")
|
|
299
441
|
UnitConfig.load_from_sources(env_values={"UNIT_KEY": "secret", "UNIT_VALUE": "x"})
|
|
@@ -658,18 +800,18 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
|
|
|
658
800
|
assert "Unit (unit)" in result.output
|
|
659
801
|
|
|
660
802
|
|
|
661
|
-
def
|
|
803
|
+
def test_cli_token_import_status_list_and_clear_hide_values(tmp_path):
|
|
662
804
|
runner = CliRunner()
|
|
663
805
|
home = tmp_path / "arch"
|
|
664
806
|
token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
|
|
665
807
|
|
|
666
|
-
|
|
808
|
+
imported = runner.invoke(
|
|
667
809
|
cli,
|
|
668
810
|
[
|
|
669
811
|
"--home",
|
|
670
812
|
str(home),
|
|
671
813
|
"token",
|
|
672
|
-
"
|
|
814
|
+
"import",
|
|
673
815
|
"PyPI",
|
|
674
816
|
"RexWzh",
|
|
675
817
|
"--token-type",
|
|
@@ -685,11 +827,12 @@ def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
|
|
|
685
827
|
input=token_json,
|
|
686
828
|
)
|
|
687
829
|
|
|
688
|
-
assert
|
|
689
|
-
assert '"
|
|
690
|
-
assert '"
|
|
691
|
-
assert "
|
|
692
|
-
assert "opaque-value-
|
|
830
|
+
assert imported.exit_code == 0, imported.output
|
|
831
|
+
assert '"source": "import"' in imported.output
|
|
832
|
+
assert '"token_present": true' in imported.output
|
|
833
|
+
assert '"token_file"' in imported.output
|
|
834
|
+
assert "opaque-value-a" not in imported.output
|
|
835
|
+
assert "opaque-value-b" not in imported.output
|
|
693
836
|
assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
694
837
|
|
|
695
838
|
status = runner.invoke(
|
|
@@ -4,7 +4,7 @@ from click.testing import CliRunner
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def test_version_present():
|
|
7
|
-
assert __version__ == "0.2.
|
|
7
|
+
assert __version__ == "0.2.7"
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_cli_version_option():
|
|
@@ -36,8 +36,9 @@ def test_tree_option_renders_registered_command_surface():
|
|
|
36
36
|
assert "├── list" in result.output
|
|
37
37
|
assert "├── status" in result.output
|
|
38
38
|
assert "├── token" in result.output
|
|
39
|
-
assert "│ ├── refresh" in result.output
|
|
40
39
|
assert "│ ├── status" in result.output
|
|
40
|
+
assert "│ ├── refresh" in result.output
|
|
41
|
+
assert "│ ├── import" in result.output
|
|
41
42
|
assert "│ ├── list" in result.output
|
|
42
43
|
assert "│ └── clear" in result.output
|
|
43
44
|
assert "├── cat [NAME]" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|