chatenv 0.2.4__tar.gz → 0.2.6__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.4/src/chatenv.egg-info → chatenv-0.2.6}/PKG-INFO +1 -1
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/__init__.py +3 -1
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/cli.py +185 -2
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/paths.py +4 -0
- chatenv-0.2.6/src/chatenv/tokens.py +211 -0
- {chatenv-0.2.4 → chatenv-0.2.6/src/chatenv.egg-info}/PKG-INFO +1 -1
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/SOURCES.txt +1 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/tests/test_chatenv_core.py +159 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/tests/test_version.py +6 -1
- {chatenv-0.2.4 → chatenv-0.2.6}/LICENSE +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/README.md +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/pyproject.toml +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/setup.cfg +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/configs.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/discovery.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/fields.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/paste.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/presets/__init__.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/registry.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/source_chain.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/store.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv/utils.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/dependency_links.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/entry_points.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/requires.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.6}/src/chatenv.egg-info/top_level.txt +0 -0
|
@@ -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 .tokens import TokenStore
|
|
6
7
|
from .configs import FeishuConfig, OpenAIConfig
|
|
7
8
|
from .discovery import get_provider_configs, get_provider_errors, load_config_providers
|
|
8
9
|
|
|
@@ -13,6 +14,7 @@ __all__ = [
|
|
|
13
14
|
"EnvStore",
|
|
14
15
|
"FeishuConfig",
|
|
15
16
|
"OpenAIConfig",
|
|
17
|
+
"TokenStore",
|
|
16
18
|
"get_provider_configs",
|
|
17
19
|
"get_provider_errors",
|
|
18
20
|
"get_paths",
|
|
@@ -20,4 +22,4 @@ __all__ = [
|
|
|
20
22
|
"__version__",
|
|
21
23
|
]
|
|
22
24
|
|
|
23
|
-
__version__ = "0.2.
|
|
25
|
+
__version__ = "0.2.6"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import sys
|
|
4
5
|
import os
|
|
5
6
|
from pathlib import Path
|
|
@@ -26,6 +27,7 @@ from .paste import iter_fields_for_values, parse_pasted_env_text
|
|
|
26
27
|
from .paths import get_paths
|
|
27
28
|
from .registry import resolve_config_types
|
|
28
29
|
from .store import EnvStore
|
|
30
|
+
from .tokens import TokenStore
|
|
29
31
|
from .utils import mask_secret
|
|
30
32
|
|
|
31
33
|
|
|
@@ -106,6 +108,7 @@ COMMAND_ORDER = [
|
|
|
106
108
|
"use",
|
|
107
109
|
"list",
|
|
108
110
|
"status",
|
|
111
|
+
"token",
|
|
109
112
|
"cat",
|
|
110
113
|
"get",
|
|
111
114
|
"set",
|
|
@@ -116,13 +119,19 @@ COMMAND_ORDER = [
|
|
|
116
119
|
|
|
117
120
|
|
|
118
121
|
class OrderedGroup(click.Group):
|
|
122
|
+
command_order = COMMAND_ORDER
|
|
123
|
+
|
|
119
124
|
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
120
125
|
commands = set(self.commands)
|
|
121
|
-
ordered = [name for name in
|
|
126
|
+
ordered = [name for name in self.command_order if name in commands]
|
|
122
127
|
ordered.extend(sorted(commands.difference(ordered)))
|
|
123
128
|
return ordered
|
|
124
129
|
|
|
125
130
|
|
|
131
|
+
class TokenCommandGroup(OrderedGroup):
|
|
132
|
+
command_order = ["status", "refresh", "list", "clear"]
|
|
133
|
+
|
|
134
|
+
|
|
126
135
|
def _format_metavar(name: str) -> str:
|
|
127
136
|
return name.replace("_", "-").upper()
|
|
128
137
|
|
|
@@ -234,7 +243,11 @@ def cli(ctx: click.Context, home: Path | None):
|
|
|
234
243
|
"""Manage typed env profiles under $CHATARCH_HOME/envs."""
|
|
235
244
|
load_config_providers()
|
|
236
245
|
paths = get_paths(home)
|
|
237
|
-
ctx.obj = {
|
|
246
|
+
ctx.obj = {
|
|
247
|
+
"paths": paths,
|
|
248
|
+
"store": EnvStore(paths.envs_dir),
|
|
249
|
+
"token_store": TokenStore(tokens_dir=paths.tokens_dir),
|
|
250
|
+
}
|
|
238
251
|
|
|
239
252
|
|
|
240
253
|
def _store(ctx: click.Context) -> EnvStore:
|
|
@@ -245,6 +258,14 @@ def _envs_dir(ctx: click.Context) -> Path:
|
|
|
245
258
|
return ctx.obj["paths"].envs_dir
|
|
246
259
|
|
|
247
260
|
|
|
261
|
+
def _token_store(ctx: click.Context) -> TokenStore:
|
|
262
|
+
return ctx.obj["token_store"]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _tokens_dir(ctx: click.Context) -> Path:
|
|
266
|
+
return ctx.obj["paths"].tokens_dir
|
|
267
|
+
|
|
268
|
+
|
|
248
269
|
def _matched_or_all(config_types: tuple[str, ...]) -> list[type[BaseEnvConfig]]:
|
|
249
270
|
return _ordered_config_classes(resolve_config_types(config_types)) if config_types else _ordered_config_classes()
|
|
250
271
|
|
|
@@ -556,6 +577,168 @@ def status_env(ctx: click.Context, config_types: tuple[str, ...], detail: bool):
|
|
|
556
577
|
click.echo(f"- {provider}: {exc}")
|
|
557
578
|
|
|
558
579
|
|
|
580
|
+
@cli.group(name="token", cls=TokenCommandGroup)
|
|
581
|
+
def token_group():
|
|
582
|
+
"""Manage generic runtime token profiles."""
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _echo_json(payload: object) -> None:
|
|
587
|
+
click.echo(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _parse_summary(items: tuple[str, ...]) -> dict[str, str]:
|
|
591
|
+
summary: dict[str, str] = {}
|
|
592
|
+
for item in items:
|
|
593
|
+
if "=" not in item:
|
|
594
|
+
raise click.ClickException("--summary values must use KEY=VALUE format.")
|
|
595
|
+
key, value = item.split("=", 1)
|
|
596
|
+
key = key.strip()
|
|
597
|
+
if not key:
|
|
598
|
+
raise click.ClickException("--summary key cannot be blank.")
|
|
599
|
+
summary[key] = value.strip()
|
|
600
|
+
return summary
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _render_token_status(payload: dict[str, object]) -> None:
|
|
604
|
+
for key in (
|
|
605
|
+
"service",
|
|
606
|
+
"profile",
|
|
607
|
+
"token_type",
|
|
608
|
+
"token_file",
|
|
609
|
+
"token_file_exists",
|
|
610
|
+
"token_present",
|
|
611
|
+
"created_at",
|
|
612
|
+
"updated_at",
|
|
613
|
+
"expires_at",
|
|
614
|
+
"source",
|
|
615
|
+
):
|
|
616
|
+
value = payload.get(key)
|
|
617
|
+
if value not in (None, ""):
|
|
618
|
+
click.echo(f"{key}={value}")
|
|
619
|
+
summary = payload.get("summary")
|
|
620
|
+
if isinstance(summary, dict) and summary:
|
|
621
|
+
click.echo("summary:")
|
|
622
|
+
for key in sorted(summary):
|
|
623
|
+
click.echo(f"- {key}={summary[key]}")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _read_token_values(*, read_stdin: bool, value_file: Path | None) -> dict[str, object]:
|
|
627
|
+
if read_stdin == bool(value_file):
|
|
628
|
+
raise click.ClickException("token refresh requires exactly one of --stdin or --file.")
|
|
629
|
+
text = sys.stdin.read() if read_stdin else value_file.read_text(encoding="utf-8") # type: ignore[union-attr]
|
|
630
|
+
try:
|
|
631
|
+
payload = json.loads(text)
|
|
632
|
+
except json.JSONDecodeError as exc:
|
|
633
|
+
raise click.ClickException(f"token payload must be a JSON object: {exc}") from exc
|
|
634
|
+
if not isinstance(payload, dict) or not payload:
|
|
635
|
+
raise click.ClickException("token payload must be a non-empty JSON object.")
|
|
636
|
+
return payload
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@token_group.command(name="refresh")
|
|
640
|
+
@click.argument("service")
|
|
641
|
+
@click.argument("profile", required=False, default="default")
|
|
642
|
+
@click.option("--stdin", "read_stdin", is_flag=True, help="Read token JSON object from stdin.")
|
|
643
|
+
@click.option("--file", "value_file", type=click.Path(dir_okay=False, path_type=Path), help="Read token JSON object from a file.")
|
|
644
|
+
@click.option("--token-type", default="runtime", show_default=True, help="Caller-defined token/session type.")
|
|
645
|
+
@click.option("--summary", multiple=True, help="Safe status field in KEY=VALUE form. Values are printed by status/list.")
|
|
646
|
+
@click.option("--expires-at", default="", help="Optional caller-provided expiry timestamp.")
|
|
647
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
648
|
+
@click.pass_context
|
|
649
|
+
def token_refresh(
|
|
650
|
+
ctx: click.Context,
|
|
651
|
+
service: str,
|
|
652
|
+
profile: str,
|
|
653
|
+
read_stdin: bool,
|
|
654
|
+
value_file: Path | None,
|
|
655
|
+
token_type: str,
|
|
656
|
+
summary: tuple[str, ...],
|
|
657
|
+
expires_at: str,
|
|
658
|
+
output_format: str,
|
|
659
|
+
):
|
|
660
|
+
"""Write refreshed generic runtime token JSON for SERVICE/PROFILE."""
|
|
661
|
+
values = _read_token_values(read_stdin=read_stdin, value_file=value_file)
|
|
662
|
+
try:
|
|
663
|
+
payload = _token_store(ctx).write(
|
|
664
|
+
service,
|
|
665
|
+
profile,
|
|
666
|
+
values=values,
|
|
667
|
+
token_type=token_type,
|
|
668
|
+
summary=_parse_summary(summary),
|
|
669
|
+
expires_at=expires_at,
|
|
670
|
+
source="refresh",
|
|
671
|
+
)
|
|
672
|
+
except ValueError as exc:
|
|
673
|
+
raise click.ClickException(str(exc)) from exc
|
|
674
|
+
if output_format == "json":
|
|
675
|
+
_echo_json(payload)
|
|
676
|
+
else:
|
|
677
|
+
_render_token_status(payload)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@token_group.command(name="status")
|
|
681
|
+
@click.argument("service")
|
|
682
|
+
@click.argument("profile", required=False, default="default")
|
|
683
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
684
|
+
@click.pass_context
|
|
685
|
+
def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
|
|
686
|
+
"""Show safe runtime token metadata for SERVICE/PROFILE."""
|
|
687
|
+
try:
|
|
688
|
+
payload = _token_store(ctx).status(service, profile)
|
|
689
|
+
except ValueError as exc:
|
|
690
|
+
raise click.ClickException(str(exc)) from exc
|
|
691
|
+
if output_format == "json":
|
|
692
|
+
_echo_json(payload)
|
|
693
|
+
else:
|
|
694
|
+
_render_token_status(payload)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@token_group.command(name="list")
|
|
698
|
+
@click.argument("service", required=False)
|
|
699
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
700
|
+
@click.pass_context
|
|
701
|
+
def token_list(ctx: click.Context, service: str | None, output_format: str):
|
|
702
|
+
"""List runtime token profiles grouped by service."""
|
|
703
|
+
payload = _token_store(ctx).list_tokens(service)
|
|
704
|
+
if output_format == "json":
|
|
705
|
+
_echo_json(payload)
|
|
706
|
+
return
|
|
707
|
+
if not payload:
|
|
708
|
+
click.echo(f"No tokens found under {_tokens_dir(ctx)}")
|
|
709
|
+
return
|
|
710
|
+
current_service = None
|
|
711
|
+
for item in payload:
|
|
712
|
+
service_name = str(item["service"])
|
|
713
|
+
if service_name != current_service:
|
|
714
|
+
if current_service is not None:
|
|
715
|
+
click.echo("")
|
|
716
|
+
click.echo(f"[{service_name}]")
|
|
717
|
+
current_service = service_name
|
|
718
|
+
suffix = " [present]" if item.get("token_present") else ""
|
|
719
|
+
click.echo(f"- {item['profile']}.json{suffix}")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@token_group.command(name="clear")
|
|
723
|
+
@click.argument("service")
|
|
724
|
+
@click.argument("profile", required=False, default="default")
|
|
725
|
+
@click.option("--execute", is_flag=True, help="Actually delete the token file. Omit for a dry run.")
|
|
726
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
727
|
+
@click.pass_context
|
|
728
|
+
def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
|
|
729
|
+
"""Clear a generic runtime token file for SERVICE/PROFILE."""
|
|
730
|
+
try:
|
|
731
|
+
payload = _token_store(ctx).clear(service, profile, execute=execute)
|
|
732
|
+
except ValueError as exc:
|
|
733
|
+
raise click.ClickException(str(exc)) from exc
|
|
734
|
+
if output_format == "json":
|
|
735
|
+
_echo_json(payload)
|
|
736
|
+
else:
|
|
737
|
+
for key, value in payload.items():
|
|
738
|
+
if value not in (None, ""):
|
|
739
|
+
click.echo(f"{key}={value}")
|
|
740
|
+
|
|
741
|
+
|
|
559
742
|
@cli.command(name="cat")
|
|
560
743
|
@click.argument("name", required=False)
|
|
561
744
|
@click.option("--no-mask", is_flag=True, help="Show sensitive values in plain text.")
|
|
@@ -13,6 +13,10 @@ class ChatArchPaths:
|
|
|
13
13
|
def envs_dir(self) -> Path:
|
|
14
14
|
return self.home_dir / "envs"
|
|
15
15
|
|
|
16
|
+
@property
|
|
17
|
+
def tokens_dir(self) -> Path:
|
|
18
|
+
return self.home_dir / "tokens"
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
def _expand_path(value: str | Path) -> Path:
|
|
18
22
|
return Path(value).expanduser().resolve()
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Generic runtime token store for ChatArch tools.
|
|
2
|
+
|
|
3
|
+
ChatEnv owns only the generic management substrate. It stores one runtime
|
|
4
|
+
state document per service/profile under ``$CHATARCH_HOME/tokens`` and never
|
|
5
|
+
interprets service-specific token semantics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import tempfile
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .paths import get_paths
|
|
19
|
+
|
|
20
|
+
TOKEN_SCHEMA_VERSION = 1
|
|
21
|
+
DEFAULT_PROFILE = "default"
|
|
22
|
+
DEFAULT_TOKEN_TYPE = "runtime"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now() -> datetime:
|
|
26
|
+
return datetime.now(timezone.utc)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _iso(value: datetime) -> str:
|
|
30
|
+
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_segment(value: str, *, default: str) -> str:
|
|
34
|
+
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "").strip())
|
|
35
|
+
cleaned = cleaned.strip(".-")
|
|
36
|
+
return cleaned or default
|
|
37
|
+
|
|
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
|
+
|
|
59
|
+
def normalize_service_name(value: str) -> str:
|
|
60
|
+
return _safe_segment(value, default="Service")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_token_profile(value: str | None) -> str:
|
|
64
|
+
return _strict_token_profile_segment(value)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
68
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TokenStore:
|
|
72
|
+
"""Generic JSON token store keyed by service/profile.
|
|
73
|
+
|
|
74
|
+
The ``values`` object is opaque and may contain secrets. ``status`` and
|
|
75
|
+
``list_tokens`` intentionally expose only safe metadata and caller-supplied
|
|
76
|
+
summary fields.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *, home: str | Path | None = None, tokens_dir: str | Path | None = None):
|
|
80
|
+
self.tokens_dir = Path(tokens_dir) if tokens_dir is not None else get_paths(home).tokens_dir
|
|
81
|
+
|
|
82
|
+
def token_path(self, service: str, profile: str | None = None) -> Path:
|
|
83
|
+
service_name = normalize_service_name(service)
|
|
84
|
+
profile_name = normalize_token_profile(profile)
|
|
85
|
+
return self.tokens_dir / service_name / f"{profile_name}.json"
|
|
86
|
+
|
|
87
|
+
def read(self, service: str, profile: str | None = None) -> dict[str, Any]:
|
|
88
|
+
path = self.token_path(service, profile)
|
|
89
|
+
try:
|
|
90
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
return {}
|
|
93
|
+
except (OSError, json.JSONDecodeError):
|
|
94
|
+
return {}
|
|
95
|
+
return payload if isinstance(payload, dict) else {}
|
|
96
|
+
|
|
97
|
+
def write(
|
|
98
|
+
self,
|
|
99
|
+
service: str,
|
|
100
|
+
profile: str | None = None,
|
|
101
|
+
*,
|
|
102
|
+
values: dict[str, Any],
|
|
103
|
+
token_type: str = DEFAULT_TOKEN_TYPE,
|
|
104
|
+
summary: dict[str, Any] | None = None,
|
|
105
|
+
expires_at: str | None = None,
|
|
106
|
+
source: str = "refresh",
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
if not isinstance(values, dict) or not values:
|
|
109
|
+
raise ValueError("values must be a non-empty JSON object")
|
|
110
|
+
service_name = normalize_service_name(service)
|
|
111
|
+
profile_name = normalize_token_profile(profile)
|
|
112
|
+
path = self.token_path(service_name, profile_name)
|
|
113
|
+
existing = self.read(service_name, profile_name)
|
|
114
|
+
now = _iso(_now())
|
|
115
|
+
payload = {
|
|
116
|
+
"schema_version": TOKEN_SCHEMA_VERSION,
|
|
117
|
+
"service": service_name,
|
|
118
|
+
"profile": profile_name,
|
|
119
|
+
"token_type": token_type or DEFAULT_TOKEN_TYPE,
|
|
120
|
+
"created_at": existing.get("created_at") or now,
|
|
121
|
+
"updated_at": now,
|
|
122
|
+
"expires_at": expires_at or "",
|
|
123
|
+
"source": source,
|
|
124
|
+
"summary": _coerce_mapping(summary),
|
|
125
|
+
"values": values,
|
|
126
|
+
}
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True)
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
131
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
132
|
+
handle.write("\n")
|
|
133
|
+
os.replace(tmp_name, path)
|
|
134
|
+
finally:
|
|
135
|
+
if os.path.exists(tmp_name):
|
|
136
|
+
os.unlink(tmp_name)
|
|
137
|
+
return self.status(service_name, profile_name)
|
|
138
|
+
|
|
139
|
+
def status(self, service: str, profile: str | None = None) -> dict[str, Any]:
|
|
140
|
+
service_name = normalize_service_name(service)
|
|
141
|
+
profile_name = normalize_token_profile(profile)
|
|
142
|
+
path = self.token_path(service_name, profile_name)
|
|
143
|
+
payload = self.read(service_name, profile_name)
|
|
144
|
+
values = payload.get("values") if isinstance(payload, dict) else None
|
|
145
|
+
token_present = isinstance(values, dict) and bool(values)
|
|
146
|
+
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
147
|
+
return {
|
|
148
|
+
"ok": True,
|
|
149
|
+
"service": service_name,
|
|
150
|
+
"profile": profile_name,
|
|
151
|
+
"token_type": payload.get("token_type") or DEFAULT_TOKEN_TYPE,
|
|
152
|
+
"token_file": str(path),
|
|
153
|
+
"token_file_exists": path.exists(),
|
|
154
|
+
"token_present": token_present,
|
|
155
|
+
"created_at": payload.get("created_at") or "",
|
|
156
|
+
"updated_at": payload.get("updated_at") or "",
|
|
157
|
+
"expires_at": payload.get("expires_at") or "",
|
|
158
|
+
"source": payload.get("source") or "",
|
|
159
|
+
"summary": summary,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def list_tokens(self, service: str | None = None) -> list[dict[str, Any]]:
|
|
163
|
+
roots: list[Path]
|
|
164
|
+
if service:
|
|
165
|
+
roots = [self.tokens_dir / normalize_service_name(service)]
|
|
166
|
+
elif self.tokens_dir.exists():
|
|
167
|
+
roots = sorted(path for path in self.tokens_dir.iterdir() if path.is_dir())
|
|
168
|
+
else:
|
|
169
|
+
roots = []
|
|
170
|
+
items: list[dict[str, Any]] = []
|
|
171
|
+
for root in roots:
|
|
172
|
+
for path in sorted(root.glob("*.json")):
|
|
173
|
+
items.append(self.status(root.name, path.stem))
|
|
174
|
+
return sorted(items, key=lambda item: (str(item["service"]), str(item["profile"])))
|
|
175
|
+
|
|
176
|
+
def clear(self, service: str, profile: str | None = None, *, execute: bool = False) -> dict[str, Any]:
|
|
177
|
+
service_name = normalize_service_name(service)
|
|
178
|
+
profile_name = normalize_token_profile(profile)
|
|
179
|
+
path = self.token_path(service_name, profile_name)
|
|
180
|
+
exists = path.exists()
|
|
181
|
+
if not execute:
|
|
182
|
+
return {
|
|
183
|
+
"ok": True,
|
|
184
|
+
"mutated": False,
|
|
185
|
+
"service": service_name,
|
|
186
|
+
"profile": profile_name,
|
|
187
|
+
"token_file": str(path),
|
|
188
|
+
"token_file_exists": exists,
|
|
189
|
+
"would_delete": str(path),
|
|
190
|
+
}
|
|
191
|
+
if exists:
|
|
192
|
+
path.unlink()
|
|
193
|
+
return {
|
|
194
|
+
"ok": True,
|
|
195
|
+
"mutated": exists,
|
|
196
|
+
"service": service_name,
|
|
197
|
+
"profile": profile_name,
|
|
198
|
+
"token_file": str(path),
|
|
199
|
+
"token_file_exists": False,
|
|
200
|
+
"deleted": exists,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = [
|
|
205
|
+
"DEFAULT_PROFILE",
|
|
206
|
+
"DEFAULT_TOKEN_TYPE",
|
|
207
|
+
"TOKEN_SCHEMA_VERSION",
|
|
208
|
+
"TokenStore",
|
|
209
|
+
"normalize_service_name",
|
|
210
|
+
"normalize_token_profile",
|
|
211
|
+
]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from types import SimpleNamespace
|
|
3
4
|
|
|
5
|
+
import pytest
|
|
4
6
|
from click.testing import CliRunner
|
|
5
7
|
import chatenv.discovery as discovery_module
|
|
6
8
|
import chatenv.cli as cli_module
|
|
@@ -240,6 +242,99 @@ def test_paths_only_use_chatarch_home(monkeypatch, tmp_path):
|
|
|
240
242
|
paths = get_paths()
|
|
241
243
|
assert paths.home_dir == (tmp_path / "arch").resolve()
|
|
242
244
|
assert paths.envs_dir == (tmp_path / "arch" / "envs").resolve()
|
|
245
|
+
assert paths.tokens_dir == (tmp_path / "arch" / "tokens").resolve()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_token_store_roundtrip_keeps_profile_parallel_to_env_without_exposing_values(tmp_path):
|
|
249
|
+
from chatenv.tokens import TokenStore
|
|
250
|
+
|
|
251
|
+
home = tmp_path / "arch"
|
|
252
|
+
store = TokenStore(home=home)
|
|
253
|
+
|
|
254
|
+
saved = store.write(
|
|
255
|
+
"CRS",
|
|
256
|
+
"admin",
|
|
257
|
+
values={"access_token": "opaque-value-a", "cookie": "opaque-value-b"},
|
|
258
|
+
token_type="admin_session",
|
|
259
|
+
summary={"base_url": "https://crs.example.test", "username": "operator"},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
assert saved["service"] == "CRS"
|
|
263
|
+
assert saved["profile"] == "admin"
|
|
264
|
+
assert saved["token_present"] is True
|
|
265
|
+
assert saved["token_file"] == str(home / "tokens" / "CRS" / "admin.json")
|
|
266
|
+
assert "opaque-value-a" not in json.dumps(saved)
|
|
267
|
+
|
|
268
|
+
assert store.read("CRS", "admin")["values"] == {
|
|
269
|
+
"access_token": "opaque-value-a",
|
|
270
|
+
"cookie": "opaque-value-b",
|
|
271
|
+
}
|
|
272
|
+
status = store.status("CRS", "admin")
|
|
273
|
+
assert status["summary"] == {"base_url": "https://crs.example.test", "username": "operator"}
|
|
274
|
+
assert "opaque-value-a" not in json.dumps(status)
|
|
275
|
+
assert "opaque-value-b" not in json.dumps(status)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_token_store_list_and_clear_are_generic(tmp_path):
|
|
279
|
+
from chatenv.tokens import TokenStore
|
|
280
|
+
|
|
281
|
+
store = TokenStore(home=tmp_path / "arch")
|
|
282
|
+
store.write("PyPI", "RexWzh", values={"session": "opaque-value-a"}, token_type="web_session")
|
|
283
|
+
store.write("CRS", "admin", values={"access_token": "opaque-value-b"}, token_type="admin_session")
|
|
284
|
+
|
|
285
|
+
all_tokens = store.list_tokens()
|
|
286
|
+
assert [(item["service"], item["profile"]) for item in all_tokens] == [("CRS", "admin"), ("PyPI", "RexWzh")]
|
|
287
|
+
assert store.list_tokens("PyPI")[0]["profile"] == "RexWzh"
|
|
288
|
+
|
|
289
|
+
dry_run = store.clear("PyPI", "RexWzh", execute=False)
|
|
290
|
+
assert dry_run["would_delete"].endswith("tokens/PyPI/RexWzh.json")
|
|
291
|
+
assert store.status("PyPI", "RexWzh")["token_present"] is True
|
|
292
|
+
|
|
293
|
+
removed = store.clear("PyPI", "RexWzh", execute=True)
|
|
294
|
+
assert removed["deleted"] is True
|
|
295
|
+
assert store.status("PyPI", "RexWzh")["token_present"] is False
|
|
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
|
|
243
338
|
|
|
244
339
|
|
|
245
340
|
def test_store_profile_roundtrip(tmp_path):
|
|
@@ -606,6 +701,69 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
|
|
|
606
701
|
assert "Unit (unit)" in result.output
|
|
607
702
|
|
|
608
703
|
|
|
704
|
+
def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
|
|
705
|
+
runner = CliRunner()
|
|
706
|
+
home = tmp_path / "arch"
|
|
707
|
+
token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
|
|
708
|
+
|
|
709
|
+
refreshed = runner.invoke(
|
|
710
|
+
cli,
|
|
711
|
+
[
|
|
712
|
+
"--home",
|
|
713
|
+
str(home),
|
|
714
|
+
"token",
|
|
715
|
+
"refresh",
|
|
716
|
+
"PyPI",
|
|
717
|
+
"RexWzh",
|
|
718
|
+
"--token-type",
|
|
719
|
+
"web_session",
|
|
720
|
+
"--summary",
|
|
721
|
+
"username=RexWzh",
|
|
722
|
+
"--summary",
|
|
723
|
+
"base_url=https://pypi.org",
|
|
724
|
+
"--stdin",
|
|
725
|
+
"--format",
|
|
726
|
+
"json",
|
|
727
|
+
],
|
|
728
|
+
input=token_json,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
assert refreshed.exit_code == 0, refreshed.output
|
|
732
|
+
assert '"token_present": true' in refreshed.output
|
|
733
|
+
assert '"token_file"' in refreshed.output
|
|
734
|
+
assert "opaque-value-a" not in refreshed.output
|
|
735
|
+
assert "opaque-value-b" not in refreshed.output
|
|
736
|
+
assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
737
|
+
|
|
738
|
+
status = runner.invoke(
|
|
739
|
+
cli,
|
|
740
|
+
["--home", str(home), "token", "status", "PyPI", "RexWzh", "--format", "json"],
|
|
741
|
+
)
|
|
742
|
+
assert status.exit_code == 0, status.output
|
|
743
|
+
assert '"profile": "RexWzh"' in status.output
|
|
744
|
+
assert '"username": "RexWzh"' in status.output
|
|
745
|
+
assert "opaque-value-a" not in status.output
|
|
746
|
+
|
|
747
|
+
listed = runner.invoke(cli, ["--home", str(home), "token", "list", "PyPI"])
|
|
748
|
+
assert listed.exit_code == 0, listed.output
|
|
749
|
+
assert "[PyPI]" in listed.output
|
|
750
|
+
assert "- RexWzh.json" in listed.output
|
|
751
|
+
assert "opaque-value-a" not in listed.output
|
|
752
|
+
|
|
753
|
+
dry_run = runner.invoke(cli, ["--home", str(home), "token", "clear", "PyPI", "RexWzh"])
|
|
754
|
+
assert dry_run.exit_code == 0, dry_run.output
|
|
755
|
+
assert "would_delete=" in dry_run.output
|
|
756
|
+
assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
757
|
+
|
|
758
|
+
cleared = runner.invoke(
|
|
759
|
+
cli,
|
|
760
|
+
["--home", str(home), "token", "clear", "PyPI", "RexWzh", "--execute", "--format", "json"],
|
|
761
|
+
)
|
|
762
|
+
assert cleared.exit_code == 0, cleared.output
|
|
763
|
+
assert '"deleted": true' in cleared.output
|
|
764
|
+
assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
765
|
+
|
|
766
|
+
|
|
609
767
|
def test_cli_init_prompts_for_config_type_when_type_missing(
|
|
610
768
|
tmp_path, monkeypatch
|
|
611
769
|
):
|
|
@@ -658,6 +816,7 @@ def test_cli_help_uses_stable_command_order():
|
|
|
658
816
|
"use",
|
|
659
817
|
"list",
|
|
660
818
|
"status",
|
|
819
|
+
"token",
|
|
661
820
|
"cat",
|
|
662
821
|
"get",
|
|
663
822
|
"set",
|
|
@@ -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.6"
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def test_cli_version_option():
|
|
@@ -35,6 +35,11 @@ def test_tree_option_renders_registered_command_surface():
|
|
|
35
35
|
assert "├── use [NAME]" in result.output
|
|
36
36
|
assert "├── list" in result.output
|
|
37
37
|
assert "├── status" in result.output
|
|
38
|
+
assert "├── token" in result.output
|
|
39
|
+
assert "│ ├── refresh" in result.output
|
|
40
|
+
assert "│ ├── status" in result.output
|
|
41
|
+
assert "│ ├── list" in result.output
|
|
42
|
+
assert "│ └── clear" in result.output
|
|
38
43
|
assert "├── cat [NAME]" in result.output
|
|
39
44
|
assert "├── get [KEY]" in result.output
|
|
40
45
|
assert "├── set [KEY-VALUE]" 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
|