chatenv 0.2.4__tar.gz → 0.2.5__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.5}/PKG-INFO +1 -1
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/__init__.py +3 -1
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/cli.py +176 -2
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/paths.py +4 -0
- chatenv-0.2.5/src/chatenv/tokens.py +191 -0
- {chatenv-0.2.4 → chatenv-0.2.5/src/chatenv.egg-info}/PKG-INFO +1 -1
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/SOURCES.txt +1 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/tests/test_chatenv_core.py +116 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/tests/test_version.py +6 -1
- {chatenv-0.2.4 → chatenv-0.2.5}/LICENSE +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/README.md +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/pyproject.toml +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/setup.cfg +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/configs.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/discovery.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/fields.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/paste.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/presets/__init__.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/registry.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/source_chain.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/store.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv/utils.py +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/dependency_links.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/entry_points.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/src/chatenv.egg-info/requires.txt +0 -0
- {chatenv-0.2.4 → chatenv-0.2.5}/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.5"
|
|
@@ -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,159 @@ 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
|
+
payload = _token_store(ctx).write(
|
|
663
|
+
service,
|
|
664
|
+
profile,
|
|
665
|
+
values=values,
|
|
666
|
+
token_type=token_type,
|
|
667
|
+
summary=_parse_summary(summary),
|
|
668
|
+
expires_at=expires_at,
|
|
669
|
+
source="refresh",
|
|
670
|
+
)
|
|
671
|
+
if output_format == "json":
|
|
672
|
+
_echo_json(payload)
|
|
673
|
+
else:
|
|
674
|
+
_render_token_status(payload)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@token_group.command(name="status")
|
|
678
|
+
@click.argument("service")
|
|
679
|
+
@click.argument("profile", required=False, default="default")
|
|
680
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
681
|
+
@click.pass_context
|
|
682
|
+
def token_status(ctx: click.Context, service: str, profile: str, output_format: str):
|
|
683
|
+
"""Show safe runtime token metadata for SERVICE/PROFILE."""
|
|
684
|
+
payload = _token_store(ctx).status(service, profile)
|
|
685
|
+
if output_format == "json":
|
|
686
|
+
_echo_json(payload)
|
|
687
|
+
else:
|
|
688
|
+
_render_token_status(payload)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@token_group.command(name="list")
|
|
692
|
+
@click.argument("service", required=False)
|
|
693
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
694
|
+
@click.pass_context
|
|
695
|
+
def token_list(ctx: click.Context, service: str | None, output_format: str):
|
|
696
|
+
"""List runtime token profiles grouped by service."""
|
|
697
|
+
payload = _token_store(ctx).list_tokens(service)
|
|
698
|
+
if output_format == "json":
|
|
699
|
+
_echo_json(payload)
|
|
700
|
+
return
|
|
701
|
+
if not payload:
|
|
702
|
+
click.echo(f"No tokens found under {_tokens_dir(ctx)}")
|
|
703
|
+
return
|
|
704
|
+
current_service = None
|
|
705
|
+
for item in payload:
|
|
706
|
+
service_name = str(item["service"])
|
|
707
|
+
if service_name != current_service:
|
|
708
|
+
if current_service is not None:
|
|
709
|
+
click.echo("")
|
|
710
|
+
click.echo(f"[{service_name}]")
|
|
711
|
+
current_service = service_name
|
|
712
|
+
suffix = " [present]" if item.get("token_present") else ""
|
|
713
|
+
click.echo(f"- {item['profile']}.json{suffix}")
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@token_group.command(name="clear")
|
|
717
|
+
@click.argument("service")
|
|
718
|
+
@click.argument("profile", required=False, default="default")
|
|
719
|
+
@click.option("--execute", is_flag=True, help="Actually delete the token file. Omit for a dry run.")
|
|
720
|
+
@click.option("--format", "output_format", type=click.Choice(["text", "json"]), default="text", show_default=True)
|
|
721
|
+
@click.pass_context
|
|
722
|
+
def token_clear(ctx: click.Context, service: str, profile: str, execute: bool, output_format: str):
|
|
723
|
+
"""Clear a generic runtime token file for SERVICE/PROFILE."""
|
|
724
|
+
payload = _token_store(ctx).clear(service, profile, execute=execute)
|
|
725
|
+
if output_format == "json":
|
|
726
|
+
_echo_json(payload)
|
|
727
|
+
else:
|
|
728
|
+
for key, value in payload.items():
|
|
729
|
+
if value not in (None, ""):
|
|
730
|
+
click.echo(f"{key}={value}")
|
|
731
|
+
|
|
732
|
+
|
|
559
733
|
@cli.command(name="cat")
|
|
560
734
|
@click.argument("name", required=False)
|
|
561
735
|
@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,191 @@
|
|
|
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 normalize_service_name(value: str) -> str:
|
|
40
|
+
return _safe_segment(value, default="Service")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_token_profile(value: str | None) -> str:
|
|
44
|
+
return _safe_segment(value or DEFAULT_PROFILE, default=DEFAULT_PROFILE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _coerce_mapping(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
48
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TokenStore:
|
|
52
|
+
"""Generic JSON token store keyed by service/profile.
|
|
53
|
+
|
|
54
|
+
The ``values`` object is opaque and may contain secrets. ``status`` and
|
|
55
|
+
``list_tokens`` intentionally expose only safe metadata and caller-supplied
|
|
56
|
+
summary fields.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, home: str | Path | None = None, tokens_dir: str | Path | None = None):
|
|
60
|
+
self.tokens_dir = Path(tokens_dir) if tokens_dir is not None else get_paths(home).tokens_dir
|
|
61
|
+
|
|
62
|
+
def token_path(self, service: str, profile: str | None = None) -> Path:
|
|
63
|
+
service_name = normalize_service_name(service)
|
|
64
|
+
profile_name = normalize_token_profile(profile)
|
|
65
|
+
return self.tokens_dir / service_name / f"{profile_name}.json"
|
|
66
|
+
|
|
67
|
+
def read(self, service: str, profile: str | None = None) -> dict[str, Any]:
|
|
68
|
+
path = self.token_path(service, profile)
|
|
69
|
+
try:
|
|
70
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
return {}
|
|
73
|
+
except (OSError, json.JSONDecodeError):
|
|
74
|
+
return {}
|
|
75
|
+
return payload if isinstance(payload, dict) else {}
|
|
76
|
+
|
|
77
|
+
def write(
|
|
78
|
+
self,
|
|
79
|
+
service: str,
|
|
80
|
+
profile: str | None = None,
|
|
81
|
+
*,
|
|
82
|
+
values: dict[str, Any],
|
|
83
|
+
token_type: str = DEFAULT_TOKEN_TYPE,
|
|
84
|
+
summary: dict[str, Any] | None = None,
|
|
85
|
+
expires_at: str | None = None,
|
|
86
|
+
source: str = "refresh",
|
|
87
|
+
) -> dict[str, Any]:
|
|
88
|
+
if not isinstance(values, dict) or not values:
|
|
89
|
+
raise ValueError("values must be a non-empty JSON object")
|
|
90
|
+
service_name = normalize_service_name(service)
|
|
91
|
+
profile_name = normalize_token_profile(profile)
|
|
92
|
+
path = self.token_path(service_name, profile_name)
|
|
93
|
+
existing = self.read(service_name, profile_name)
|
|
94
|
+
now = _iso(_now())
|
|
95
|
+
payload = {
|
|
96
|
+
"schema_version": TOKEN_SCHEMA_VERSION,
|
|
97
|
+
"service": service_name,
|
|
98
|
+
"profile": profile_name,
|
|
99
|
+
"token_type": token_type or DEFAULT_TOKEN_TYPE,
|
|
100
|
+
"created_at": existing.get("created_at") or now,
|
|
101
|
+
"updated_at": now,
|
|
102
|
+
"expires_at": expires_at or "",
|
|
103
|
+
"source": source,
|
|
104
|
+
"summary": _coerce_mapping(summary),
|
|
105
|
+
"values": values,
|
|
106
|
+
}
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent), text=True)
|
|
109
|
+
try:
|
|
110
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
111
|
+
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
112
|
+
handle.write("\n")
|
|
113
|
+
os.replace(tmp_name, path)
|
|
114
|
+
finally:
|
|
115
|
+
if os.path.exists(tmp_name):
|
|
116
|
+
os.unlink(tmp_name)
|
|
117
|
+
return self.status(service_name, profile_name)
|
|
118
|
+
|
|
119
|
+
def status(self, service: str, profile: str | None = None) -> dict[str, Any]:
|
|
120
|
+
service_name = normalize_service_name(service)
|
|
121
|
+
profile_name = normalize_token_profile(profile)
|
|
122
|
+
path = self.token_path(service_name, profile_name)
|
|
123
|
+
payload = self.read(service_name, profile_name)
|
|
124
|
+
values = payload.get("values") if isinstance(payload, dict) else None
|
|
125
|
+
token_present = isinstance(values, dict) and bool(values)
|
|
126
|
+
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
127
|
+
return {
|
|
128
|
+
"ok": True,
|
|
129
|
+
"service": service_name,
|
|
130
|
+
"profile": profile_name,
|
|
131
|
+
"token_type": payload.get("token_type") or DEFAULT_TOKEN_TYPE,
|
|
132
|
+
"token_file": str(path),
|
|
133
|
+
"token_file_exists": path.exists(),
|
|
134
|
+
"token_present": token_present,
|
|
135
|
+
"created_at": payload.get("created_at") or "",
|
|
136
|
+
"updated_at": payload.get("updated_at") or "",
|
|
137
|
+
"expires_at": payload.get("expires_at") or "",
|
|
138
|
+
"source": payload.get("source") or "",
|
|
139
|
+
"summary": summary,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def list_tokens(self, service: str | None = None) -> list[dict[str, Any]]:
|
|
143
|
+
roots: list[Path]
|
|
144
|
+
if service:
|
|
145
|
+
roots = [self.tokens_dir / normalize_service_name(service)]
|
|
146
|
+
elif self.tokens_dir.exists():
|
|
147
|
+
roots = sorted(path for path in self.tokens_dir.iterdir() if path.is_dir())
|
|
148
|
+
else:
|
|
149
|
+
roots = []
|
|
150
|
+
items: list[dict[str, Any]] = []
|
|
151
|
+
for root in roots:
|
|
152
|
+
for path in sorted(root.glob("*.json")):
|
|
153
|
+
items.append(self.status(root.name, path.stem))
|
|
154
|
+
return sorted(items, key=lambda item: (str(item["service"]), str(item["profile"])))
|
|
155
|
+
|
|
156
|
+
def clear(self, service: str, profile: str | None = None, *, execute: bool = False) -> dict[str, Any]:
|
|
157
|
+
service_name = normalize_service_name(service)
|
|
158
|
+
profile_name = normalize_token_profile(profile)
|
|
159
|
+
path = self.token_path(service_name, profile_name)
|
|
160
|
+
exists = path.exists()
|
|
161
|
+
if not execute:
|
|
162
|
+
return {
|
|
163
|
+
"ok": True,
|
|
164
|
+
"mutated": False,
|
|
165
|
+
"service": service_name,
|
|
166
|
+
"profile": profile_name,
|
|
167
|
+
"token_file": str(path),
|
|
168
|
+
"token_file_exists": exists,
|
|
169
|
+
"would_delete": str(path),
|
|
170
|
+
}
|
|
171
|
+
if exists:
|
|
172
|
+
path.unlink()
|
|
173
|
+
return {
|
|
174
|
+
"ok": True,
|
|
175
|
+
"mutated": exists,
|
|
176
|
+
"service": service_name,
|
|
177
|
+
"profile": profile_name,
|
|
178
|
+
"token_file": str(path),
|
|
179
|
+
"token_file_exists": False,
|
|
180
|
+
"deleted": exists,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
__all__ = [
|
|
185
|
+
"DEFAULT_PROFILE",
|
|
186
|
+
"DEFAULT_TOKEN_TYPE",
|
|
187
|
+
"TOKEN_SCHEMA_VERSION",
|
|
188
|
+
"TokenStore",
|
|
189
|
+
"normalize_service_name",
|
|
190
|
+
"normalize_token_profile",
|
|
191
|
+
]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from types import SimpleNamespace
|
|
3
4
|
|
|
@@ -240,6 +241,57 @@ def test_paths_only_use_chatarch_home(monkeypatch, tmp_path):
|
|
|
240
241
|
paths = get_paths()
|
|
241
242
|
assert paths.home_dir == (tmp_path / "arch").resolve()
|
|
242
243
|
assert paths.envs_dir == (tmp_path / "arch" / "envs").resolve()
|
|
244
|
+
assert paths.tokens_dir == (tmp_path / "arch" / "tokens").resolve()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_token_store_roundtrip_keeps_profile_parallel_to_env_without_exposing_values(tmp_path):
|
|
248
|
+
from chatenv.tokens import TokenStore
|
|
249
|
+
|
|
250
|
+
home = tmp_path / "arch"
|
|
251
|
+
store = TokenStore(home=home)
|
|
252
|
+
|
|
253
|
+
saved = store.write(
|
|
254
|
+
"CRS",
|
|
255
|
+
"admin",
|
|
256
|
+
values={"access_token": "opaque-value-a", "cookie": "opaque-value-b"},
|
|
257
|
+
token_type="admin_session",
|
|
258
|
+
summary={"base_url": "https://crs.example.test", "username": "operator"},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
assert saved["service"] == "CRS"
|
|
262
|
+
assert saved["profile"] == "admin"
|
|
263
|
+
assert saved["token_present"] is True
|
|
264
|
+
assert saved["token_file"] == str(home / "tokens" / "CRS" / "admin.json")
|
|
265
|
+
assert "opaque-value-a" not in json.dumps(saved)
|
|
266
|
+
|
|
267
|
+
assert store.read("CRS", "admin")["values"] == {
|
|
268
|
+
"access_token": "opaque-value-a",
|
|
269
|
+
"cookie": "opaque-value-b",
|
|
270
|
+
}
|
|
271
|
+
status = store.status("CRS", "admin")
|
|
272
|
+
assert status["summary"] == {"base_url": "https://crs.example.test", "username": "operator"}
|
|
273
|
+
assert "opaque-value-a" not in json.dumps(status)
|
|
274
|
+
assert "opaque-value-b" not in json.dumps(status)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_token_store_list_and_clear_are_generic(tmp_path):
|
|
278
|
+
from chatenv.tokens import TokenStore
|
|
279
|
+
|
|
280
|
+
store = TokenStore(home=tmp_path / "arch")
|
|
281
|
+
store.write("PyPI", "RexWzh", values={"session": "opaque-value-a"}, token_type="web_session")
|
|
282
|
+
store.write("CRS", "admin", values={"access_token": "opaque-value-b"}, token_type="admin_session")
|
|
283
|
+
|
|
284
|
+
all_tokens = store.list_tokens()
|
|
285
|
+
assert [(item["service"], item["profile"]) for item in all_tokens] == [("CRS", "admin"), ("PyPI", "RexWzh")]
|
|
286
|
+
assert store.list_tokens("PyPI")[0]["profile"] == "RexWzh"
|
|
287
|
+
|
|
288
|
+
dry_run = store.clear("PyPI", "RexWzh", execute=False)
|
|
289
|
+
assert dry_run["would_delete"].endswith("tokens/PyPI/RexWzh.json")
|
|
290
|
+
assert store.status("PyPI", "RexWzh")["token_present"] is True
|
|
291
|
+
|
|
292
|
+
removed = store.clear("PyPI", "RexWzh", execute=True)
|
|
293
|
+
assert removed["deleted"] is True
|
|
294
|
+
assert store.status("PyPI", "RexWzh")["token_present"] is False
|
|
243
295
|
|
|
244
296
|
|
|
245
297
|
def test_store_profile_roundtrip(tmp_path):
|
|
@@ -606,6 +658,69 @@ def test_cli_new_bad_type_shows_available_types_outside_interactive(tmp_path):
|
|
|
606
658
|
assert "Unit (unit)" in result.output
|
|
607
659
|
|
|
608
660
|
|
|
661
|
+
def test_cli_token_refresh_status_list_and_clear_hide_values(tmp_path):
|
|
662
|
+
runner = CliRunner()
|
|
663
|
+
home = tmp_path / "arch"
|
|
664
|
+
token_json = json.dumps({"access_token": "opaque-value-a", "cookie": "opaque-value-b"})
|
|
665
|
+
|
|
666
|
+
refreshed = runner.invoke(
|
|
667
|
+
cli,
|
|
668
|
+
[
|
|
669
|
+
"--home",
|
|
670
|
+
str(home),
|
|
671
|
+
"token",
|
|
672
|
+
"refresh",
|
|
673
|
+
"PyPI",
|
|
674
|
+
"RexWzh",
|
|
675
|
+
"--token-type",
|
|
676
|
+
"web_session",
|
|
677
|
+
"--summary",
|
|
678
|
+
"username=RexWzh",
|
|
679
|
+
"--summary",
|
|
680
|
+
"base_url=https://pypi.org",
|
|
681
|
+
"--stdin",
|
|
682
|
+
"--format",
|
|
683
|
+
"json",
|
|
684
|
+
],
|
|
685
|
+
input=token_json,
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
assert refreshed.exit_code == 0, refreshed.output
|
|
689
|
+
assert '"token_present": true' in refreshed.output
|
|
690
|
+
assert '"token_file"' in refreshed.output
|
|
691
|
+
assert "opaque-value-a" not in refreshed.output
|
|
692
|
+
assert "opaque-value-b" not in refreshed.output
|
|
693
|
+
assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
694
|
+
|
|
695
|
+
status = runner.invoke(
|
|
696
|
+
cli,
|
|
697
|
+
["--home", str(home), "token", "status", "PyPI", "RexWzh", "--format", "json"],
|
|
698
|
+
)
|
|
699
|
+
assert status.exit_code == 0, status.output
|
|
700
|
+
assert '"profile": "RexWzh"' in status.output
|
|
701
|
+
assert '"username": "RexWzh"' in status.output
|
|
702
|
+
assert "opaque-value-a" not in status.output
|
|
703
|
+
|
|
704
|
+
listed = runner.invoke(cli, ["--home", str(home), "token", "list", "PyPI"])
|
|
705
|
+
assert listed.exit_code == 0, listed.output
|
|
706
|
+
assert "[PyPI]" in listed.output
|
|
707
|
+
assert "- RexWzh.json" in listed.output
|
|
708
|
+
assert "opaque-value-a" not in listed.output
|
|
709
|
+
|
|
710
|
+
dry_run = runner.invoke(cli, ["--home", str(home), "token", "clear", "PyPI", "RexWzh"])
|
|
711
|
+
assert dry_run.exit_code == 0, dry_run.output
|
|
712
|
+
assert "would_delete=" in dry_run.output
|
|
713
|
+
assert (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
714
|
+
|
|
715
|
+
cleared = runner.invoke(
|
|
716
|
+
cli,
|
|
717
|
+
["--home", str(home), "token", "clear", "PyPI", "RexWzh", "--execute", "--format", "json"],
|
|
718
|
+
)
|
|
719
|
+
assert cleared.exit_code == 0, cleared.output
|
|
720
|
+
assert '"deleted": true' in cleared.output
|
|
721
|
+
assert not (home / "tokens" / "PyPI" / "RexWzh.json").exists()
|
|
722
|
+
|
|
723
|
+
|
|
609
724
|
def test_cli_init_prompts_for_config_type_when_type_missing(
|
|
610
725
|
tmp_path, monkeypatch
|
|
611
726
|
):
|
|
@@ -658,6 +773,7 @@ def test_cli_help_uses_stable_command_order():
|
|
|
658
773
|
"use",
|
|
659
774
|
"list",
|
|
660
775
|
"status",
|
|
776
|
+
"token",
|
|
661
777
|
"cat",
|
|
662
778
|
"get",
|
|
663
779
|
"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.5"
|
|
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
|