kd-cli 2.1.5__tar.gz → 2.1.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.
- {kd_cli-2.1.5/kd_cli.egg-info → kd_cli-2.1.6}/PKG-INFO +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/saved_queries.py +1 -2
- kd_cli-2.1.6/kd/auth.py +173 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/main.py +3 -2
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/system/__init__.py +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/config.py +22 -6
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/connection.py +14 -1
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/exceptions.py +12 -5
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/saved_query.py +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/CHANGELOG.md +14 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6/kd_cli.egg-info}/PKG-INFO +1 -1
- {kd_cli-2.1.5 → kd_cli-2.1.6}/pyproject.toml +1 -1
- kd_cli-2.1.5/kd/auth.py +0 -96
- {kd_cli-2.1.5 → kd_cli-2.1.6}/LICENSE +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/MANIFEST.in +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/README.md +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/articles.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/http.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/hub.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/tags.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/api/youtrack.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/article_marker.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/command_base.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/command_parser.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/namespaces.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/core/option_registry.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/input.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/alias/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/article/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/init.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/issue/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/primer/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/project/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/query/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/release_notes.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/search/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/tag/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/time/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/namespaces/user/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/output.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/cli/tree.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/client.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/config_schema.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/debug.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/history.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/article.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/assist.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/attachment.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/comment.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/common.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/issue.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/project.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/search.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/tag.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/user.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/models/work_item.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/primer.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/query_parser.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/release_notes.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/__init__.py +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/command-synonyms.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/config-template.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd/resources/error-messages.yaml +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/SOURCES.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/dependency_links.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/entry_points.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/requires.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/kd_cli.egg-info/top_level.txt +0 -0
- {kd_cli-2.1.5 → kd_cli-2.1.6}/setup.cfg +0 -0
|
@@ -65,8 +65,7 @@ class SavedQueriesAPI:
|
|
|
65
65
|
"""List saved queries owned by a specific user.
|
|
66
66
|
|
|
67
67
|
The literal string ``"me"`` is accepted as ``user_id`` and resolves
|
|
68
|
-
to the authenticated user server-side
|
|
69
|
-
``vod.youtrack.cloud``).
|
|
68
|
+
to the authenticated user server-side.
|
|
70
69
|
"""
|
|
71
70
|
data = self._http.get(
|
|
72
71
|
f"/users/{user_id}/savedQueries",
|
kd_cli-2.1.6/kd/auth.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Connection-aware token resolution.
|
|
2
|
+
|
|
3
|
+
Each connection resolves its token from one of two sources:
|
|
4
|
+
|
|
5
|
+
1. ``token_cmd`` — shell command that prints the token to stdout.
|
|
6
|
+
2. ``token_env`` — environment variable name containing the token.
|
|
7
|
+
|
|
8
|
+
Priority: ``token_cmd`` > ``token_env``. Secrets never live in config
|
|
9
|
+
files — only references to external sources.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from kd.exceptions import AuthError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from kd.config import ConnectionOrigin
|
|
22
|
+
from kd.connection import ConnectionConfig
|
|
23
|
+
from kd.debug import DebugLog
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_connection_token(
|
|
27
|
+
conn: ConnectionConfig,
|
|
28
|
+
*,
|
|
29
|
+
name: str | None = None,
|
|
30
|
+
origin: ConnectionOrigin | None = None,
|
|
31
|
+
debug_log: DebugLog | None = None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Resolve the API token for a connection.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
conn:
|
|
38
|
+
The connection config whose auth sources to try.
|
|
39
|
+
name:
|
|
40
|
+
Optional connection name, used in error messages to name the
|
|
41
|
+
failing connection.
|
|
42
|
+
origin:
|
|
43
|
+
Optional per-key origin map, used to cite the exact config file
|
|
44
|
+
that declared the (now-broken) auth key.
|
|
45
|
+
debug_log:
|
|
46
|
+
Optional debug logger.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
str
|
|
51
|
+
The resolved token.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
AuthError
|
|
56
|
+
If no token can be resolved from any source. When *name* and
|
|
57
|
+
*origin* are supplied, the error carries a multi-line
|
|
58
|
+
``.suggestion`` with connection-specific guidance and a
|
|
59
|
+
``Config: <path>`` pointer.
|
|
60
|
+
"""
|
|
61
|
+
if conn.token_cmd:
|
|
62
|
+
if debug_log:
|
|
63
|
+
debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
|
|
64
|
+
return _run_token_cmd(conn.token_cmd)
|
|
65
|
+
|
|
66
|
+
if conn.token_env:
|
|
67
|
+
if debug_log:
|
|
68
|
+
debug_log.info("auth", f"reading token from env var: {conn.token_env}")
|
|
69
|
+
value = os.environ.get(conn.token_env)
|
|
70
|
+
if value:
|
|
71
|
+
return value
|
|
72
|
+
raise _missing_token_error(conn, name, origin)
|
|
73
|
+
|
|
74
|
+
raise _no_auth_source_error(conn, name, origin)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_token_cmd(cmd: str) -> str:
|
|
78
|
+
"""Execute a token command and return its stdout.
|
|
79
|
+
|
|
80
|
+
Raises :class:`AuthError` on non-zero exit or empty output.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
cmd,
|
|
85
|
+
shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=10,
|
|
89
|
+
)
|
|
90
|
+
except subprocess.TimeoutExpired:
|
|
91
|
+
raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
|
|
92
|
+
|
|
93
|
+
if proc.returncode != 0:
|
|
94
|
+
stderr = proc.stderr.strip()
|
|
95
|
+
detail = f": {stderr}" if stderr else ""
|
|
96
|
+
raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
|
|
97
|
+
|
|
98
|
+
token = proc.stdout.strip()
|
|
99
|
+
if not token:
|
|
100
|
+
raise AuthError(f"token_cmd returned empty output: {cmd}")
|
|
101
|
+
|
|
102
|
+
return token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _missing_token_error(
|
|
106
|
+
conn: ConnectionConfig,
|
|
107
|
+
name: str | None,
|
|
108
|
+
origin: ConnectionOrigin | None,
|
|
109
|
+
) -> AuthError:
|
|
110
|
+
"""Build an ``AuthError`` for a ``token_env`` that points at an unset var."""
|
|
111
|
+
env_name = conn.token_env or "<token_env>"
|
|
112
|
+
where = _describe_connection(conn, name)
|
|
113
|
+
message = f"{where} needs a token — environment variable '{env_name}' is not set."
|
|
114
|
+
suggestion = _build_suggestion(
|
|
115
|
+
lines=[
|
|
116
|
+
f"Either export {env_name} or configure `token_cmd:` on the connection:",
|
|
117
|
+
f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
|
|
118
|
+
],
|
|
119
|
+
origin=origin,
|
|
120
|
+
key="token_env",
|
|
121
|
+
)
|
|
122
|
+
return AuthError(message, suggestion=suggestion)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _no_auth_source_error(
|
|
126
|
+
conn: ConnectionConfig,
|
|
127
|
+
name: str | None,
|
|
128
|
+
origin: ConnectionOrigin | None,
|
|
129
|
+
) -> AuthError:
|
|
130
|
+
"""Build an ``AuthError`` for a connection with no ``token_cmd`` or ``token_env``."""
|
|
131
|
+
where = _describe_connection(conn, name)
|
|
132
|
+
message = f"{where} has no auth source configured."
|
|
133
|
+
suggestion = _build_suggestion(
|
|
134
|
+
lines=[
|
|
135
|
+
"Set one of `token_cmd:` or `token_env:` on the connection:",
|
|
136
|
+
f" connections.{name or '<name>'}.token_cmd: gopass show -o path/to/token",
|
|
137
|
+
f" connections.{name or '<name>'}.token_env: KD_{(name or 'NAME').upper()}_TOKEN",
|
|
138
|
+
],
|
|
139
|
+
origin=origin,
|
|
140
|
+
key=None,
|
|
141
|
+
)
|
|
142
|
+
return AuthError(message, suggestion=suggestion)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _describe_connection(conn: ConnectionConfig, name: str | None) -> str:
|
|
146
|
+
"""Render the ``Connection '<name>' (<url>)`` prefix, degraded when no name."""
|
|
147
|
+
if name:
|
|
148
|
+
return f"Connection '{name}' ({conn.url})"
|
|
149
|
+
return f"Connection ({conn.url})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_suggestion(
|
|
153
|
+
*,
|
|
154
|
+
lines: list[str],
|
|
155
|
+
origin: ConnectionOrigin | None,
|
|
156
|
+
key: str | None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Assemble a multi-line suggestion, appending ``Config: <path>`` when known.
|
|
159
|
+
|
|
160
|
+
When *origin* is provided, prefer the per-key origin for *key* (which
|
|
161
|
+
points at the exact file declaring that key); fall back to the
|
|
162
|
+
connection's primary path. Omit the line entirely when no origin is
|
|
163
|
+
known so tests and ad-hoc callers don't see a dangling ``Config:``.
|
|
164
|
+
"""
|
|
165
|
+
path: str | None = None
|
|
166
|
+
if origin is not None:
|
|
167
|
+
if key and key in origin.keys:
|
|
168
|
+
path = origin.keys[key].path
|
|
169
|
+
else:
|
|
170
|
+
path = origin.path
|
|
171
|
+
if path:
|
|
172
|
+
lines = [*lines, f"Config: {path}"]
|
|
173
|
+
return "\n".join(lines)
|
|
@@ -29,7 +29,7 @@ from kd.client import YouTrackClient
|
|
|
29
29
|
from kd.config import ResolvedConfig, resolve_command_defaults, resolve_config
|
|
30
30
|
from kd.connection import ConnectionContext, resolve_connection
|
|
31
31
|
from kd.debug import DebugLevel, DebugLog, init_debug_log
|
|
32
|
-
from kd.exceptions import
|
|
32
|
+
from kd.exceptions import KdError
|
|
33
33
|
|
|
34
34
|
# Global options — single source of truth for extraction, help, and reference.
|
|
35
35
|
GLOBAL_OPTIONS: list[OptionDeclaration] = [
|
|
@@ -121,7 +121,7 @@ def expand_toplevel_alias(
|
|
|
121
121
|
def _print_error(error: KdError) -> None:
|
|
122
122
|
"""Print error with optional suggestion to stderr."""
|
|
123
123
|
print(f"Error: {error}", file=sys.stderr)
|
|
124
|
-
if
|
|
124
|
+
if error.suggestion:
|
|
125
125
|
print(f"Hint: {error.suggestion}", file=sys.stderr)
|
|
126
126
|
|
|
127
127
|
|
|
@@ -272,6 +272,7 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
272
272
|
connection = resolve_connection(
|
|
273
273
|
connections=resolved.connections,
|
|
274
274
|
default_connection=resolved.default_connection_name,
|
|
275
|
+
connection_origins=resolved.connection_origins,
|
|
275
276
|
debug_log=debug_log,
|
|
276
277
|
)
|
|
277
278
|
client = YouTrackClient(
|
|
@@ -296,7 +296,7 @@ class ConfigSetCommand(Command):
|
|
|
296
296
|
),
|
|
297
297
|
examples=[
|
|
298
298
|
"kd /system/config set default_connection corp",
|
|
299
|
-
"kd /system/config set connections.
|
|
299
|
+
"kd /system/config set connections.support.default_project BCO --local",
|
|
300
300
|
"kd /system/config set history.retention 50 --global",
|
|
301
301
|
],
|
|
302
302
|
see_also=["/system/config get", "/system/config keys", "/system/config list"],
|
|
@@ -452,7 +452,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
|
|
|
452
452
|
coerced = TypeAdapter(entry.annotation).validate_python(value)
|
|
453
453
|
except ValidationError as e:
|
|
454
454
|
details = _format_validation_errors(e)
|
|
455
|
-
raise ConfigError(f"Invalid value for {path}: {details}") from e
|
|
455
|
+
raise ConfigError(f"Invalid value for {path} in {config_path}: {details}") from e
|
|
456
456
|
|
|
457
457
|
# Load raw YAML, capture original text for rollback.
|
|
458
458
|
raw = _load_raw_yaml(config_path)
|
|
@@ -481,7 +481,7 @@ def update_config_path(path: str, value: str, config_path: Path) -> tuple[str, A
|
|
|
481
481
|
) from e
|
|
482
482
|
details = _format_validation_errors(e)
|
|
483
483
|
raise ConfigError(
|
|
484
|
-
f"Config would be invalid after setting {path}: {details}",
|
|
484
|
+
f"Config would be invalid after setting {path} in {config_path}: {details}",
|
|
485
485
|
) from e
|
|
486
486
|
|
|
487
487
|
return path, coerced
|
|
@@ -864,7 +864,7 @@ def _resolve_connections(
|
|
|
864
864
|
where = f" in {primary_path}"
|
|
865
865
|
if isinstance(e, ValidationError):
|
|
866
866
|
d.trace("config", f"connection '{name}' validation error: {e!r}")
|
|
867
|
-
details = _format_validation_errors(e)
|
|
867
|
+
details = _format_validation_errors(e, key_origins)
|
|
868
868
|
else:
|
|
869
869
|
details = str(e)
|
|
870
870
|
message = f"Invalid connection '{name}'{where}: {details}"
|
|
@@ -873,17 +873,33 @@ def _resolve_connections(
|
|
|
873
873
|
return result, origins
|
|
874
874
|
|
|
875
875
|
|
|
876
|
-
def _format_validation_errors(
|
|
876
|
+
def _format_validation_errors(
|
|
877
|
+
err: ValidationError,
|
|
878
|
+
key_origins: dict[str, ConnectionKeyOrigin] | None = None,
|
|
879
|
+
) -> str:
|
|
877
880
|
"""Render a Pydantic ``ValidationError`` as a user-facing summary.
|
|
878
881
|
|
|
879
882
|
Drops the ``input_value=...`` dump and the ``errors.pydantic.dev`` URL,
|
|
880
883
|
keeping only ``<dotted-loc>: <msg>`` parts joined by ``; ``.
|
|
884
|
+
|
|
885
|
+
When *key_origins* is supplied, each locus is annotated with the config
|
|
886
|
+
file that contributed its top-level key, e.g.
|
|
887
|
+
``default_project: Input should be a string (from /path/.kd/config.yaml)``.
|
|
888
|
+
Origin tracking is per-top-level-key because deep merges on
|
|
889
|
+
``defaults`` and ``compatibility`` replace the whole sub-dict from
|
|
890
|
+
whichever tier wrote last.
|
|
881
891
|
"""
|
|
882
892
|
parts: list[str] = []
|
|
883
893
|
for item in err.errors():
|
|
884
|
-
|
|
894
|
+
loc_parts = item.get("loc", ())
|
|
895
|
+
loc = ".".join(str(p) for p in loc_parts)
|
|
885
896
|
msg = item.get("msg", "")
|
|
886
|
-
|
|
897
|
+
origin_suffix = ""
|
|
898
|
+
if key_origins and loc_parts:
|
|
899
|
+
top_key = str(loc_parts[0])
|
|
900
|
+
if top_key in key_origins:
|
|
901
|
+
origin_suffix = f" (from {key_origins[top_key].path})"
|
|
902
|
+
parts.append(f"{loc}: {msg}{origin_suffix}" if loc else msg)
|
|
887
903
|
return "; ".join(parts) if parts else str(err)
|
|
888
904
|
|
|
889
905
|
|
|
@@ -19,6 +19,9 @@ from kd.auth import resolve_connection_token
|
|
|
19
19
|
from kd.exceptions import ConfigError
|
|
20
20
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
|
|
24
|
+
from kd.config import ConnectionOrigin
|
|
22
25
|
from kd.debug import DebugLog
|
|
23
26
|
|
|
24
27
|
|
|
@@ -132,6 +135,7 @@ def resolve_connection(
|
|
|
132
135
|
connections: dict[str, ConnectionConfig],
|
|
133
136
|
default_connection: str | None,
|
|
134
137
|
*,
|
|
138
|
+
connection_origins: Mapping[str, ConnectionOrigin] | None = None,
|
|
135
139
|
debug_log: DebugLog | None = None,
|
|
136
140
|
) -> ConnectionContext:
|
|
137
141
|
"""Resolve the active connection into a :class:`ConnectionContext`.
|
|
@@ -142,6 +146,9 @@ def resolve_connection(
|
|
|
142
146
|
All parsed ``ConnectionConfig`` objects keyed by name.
|
|
143
147
|
default_connection:
|
|
144
148
|
Name of the connection to resolve (from ``default_connection`` config key).
|
|
149
|
+
connection_origins:
|
|
150
|
+
Per-connection provenance from :class:`ResolvedConfig`, used to
|
|
151
|
+
cite the originating config file in auth-error messages.
|
|
145
152
|
debug_log:
|
|
146
153
|
Optional debug logger.
|
|
147
154
|
"""
|
|
@@ -173,7 +180,13 @@ def resolve_connection(
|
|
|
173
180
|
if debug_log:
|
|
174
181
|
debug_log.info("connection", f"resolving connection '{default_connection}'")
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
origin = connection_origins.get(default_connection) if connection_origins else None
|
|
184
|
+
token = resolve_connection_token(
|
|
185
|
+
conn,
|
|
186
|
+
name=default_connection,
|
|
187
|
+
origin=origin,
|
|
188
|
+
debug_log=debug_log,
|
|
189
|
+
)
|
|
177
190
|
|
|
178
191
|
if debug_log:
|
|
179
192
|
debug_log.info("connection", f"url={conn.url}")
|
|
@@ -17,7 +17,16 @@ import yaml
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
class KdError(Exception):
|
|
20
|
-
"""Base exception for all kd errors.
|
|
20
|
+
"""Base exception for all kd errors.
|
|
21
|
+
|
|
22
|
+
Carries an optional :attr:`suggestion` that ``_print_error`` renders
|
|
23
|
+
as a ``Hint:`` line. Subclasses may pass it through their own
|
|
24
|
+
constructors or rely on this default.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, *, suggestion: str | None = None) -> None:
|
|
28
|
+
self.suggestion = suggestion
|
|
29
|
+
super().__init__(message)
|
|
21
30
|
|
|
22
31
|
|
|
23
32
|
class AuthError(KdError):
|
|
@@ -28,8 +37,7 @@ class ConfigError(KdError):
|
|
|
28
37
|
"""Config file missing, malformed, or incomplete."""
|
|
29
38
|
|
|
30
39
|
def __init__(self, message: str, suggestion: str | None = None) -> None:
|
|
31
|
-
|
|
32
|
-
super().__init__(message)
|
|
40
|
+
super().__init__(message, suggestion=suggestion)
|
|
33
41
|
|
|
34
42
|
|
|
35
43
|
class APIError(KdError):
|
|
@@ -44,8 +52,7 @@ class APIError(KdError):
|
|
|
44
52
|
) -> None:
|
|
45
53
|
self.status_code = status_code
|
|
46
54
|
self.response_body = response_body
|
|
47
|
-
|
|
48
|
-
super().__init__(message)
|
|
55
|
+
super().__init__(message, suggestion=suggestion)
|
|
49
56
|
|
|
50
57
|
|
|
51
58
|
class NotFoundError(APIError):
|
|
@@ -36,7 +36,7 @@ class SavedQueryUpdate(BaseModel):
|
|
|
36
36
|
|
|
37
37
|
All fields optional. Dump with ``exclude_none=True`` so the POST body only
|
|
38
38
|
carries the fields the caller actually wants to change; YouTrack preserves
|
|
39
|
-
omitted fields
|
|
39
|
+
omitted fields.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
model_config = ConfigDict(populate_by_name=True)
|
|
@@ -4,6 +4,20 @@ All notable changes to kd-cli are recorded here. The project follows [Semantic V
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.1.6] — 2026-04-17
|
|
8
|
+
|
|
9
|
+
Milestone 005 — CLI friction audit, session S2. Two raise sites enriched so users land on the right file and the right fix.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Missing-token errors now name the connection and URL, surface a copy-pasteable `token_cmd:` example, and cite the config file that declared `token_env:`. Previously the user saw only the env-var name — in a multi-connection setup, guesswork about which connection failed and which file to edit (KDCLI-72).
|
|
13
|
+
- Config merge-validation errors cite the contributing file for each offending locus. A bad local override now names the local file per key; a bad global-only entry names the global file. `kd /system/config set` leaf-coercion errors name the target config path. Origin data was already tracked via `ConnectionOrigin` for `config list`; this release threads it into the user-visible error string too (KDCLI-69).
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- The `.suggestion` attribute now lives on the `KdError` base instead of being declared per-subclass. `_print_error` renders `Hint:` for any `KdError` with a suggestion — `AuthError` and `ValidationError` now carry suggestions alongside `APIError` and `ConfigError`. Internal plumbing; no CLI surface change.
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
- Replace `vod` with `support` in help-text examples, code docstrings, current-behavior design docs, specs, how-tos, and test fixtures. Historical records (session docs, CHANGELOG, shipped milestone close-outs) keep their `vod` references intact so the audit trail stays honest (KDCLI-30).
|
|
20
|
+
|
|
7
21
|
## [2.1.5] — 2026-04-17
|
|
8
22
|
|
|
9
23
|
Milestone 005 — CLI friction audit, session S1. Four first-time-user surface fixes on the `--help` / `--output` axis.
|
kd_cli-2.1.5/kd/auth.py
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
"""Connection-aware token resolution.
|
|
2
|
-
|
|
3
|
-
Each connection resolves its token from one of two sources:
|
|
4
|
-
|
|
5
|
-
1. ``token_cmd`` — shell command that prints the token to stdout.
|
|
6
|
-
2. ``token_env`` — environment variable name containing the token.
|
|
7
|
-
|
|
8
|
-
Priority: ``token_cmd`` > ``token_env``. Secrets never live in config
|
|
9
|
-
files — only references to external sources.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import os
|
|
15
|
-
import subprocess
|
|
16
|
-
from typing import TYPE_CHECKING
|
|
17
|
-
|
|
18
|
-
from kd.exceptions import AuthError
|
|
19
|
-
|
|
20
|
-
if TYPE_CHECKING:
|
|
21
|
-
from kd.connection import ConnectionConfig
|
|
22
|
-
from kd.debug import DebugLog
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def resolve_connection_token(
|
|
26
|
-
conn: ConnectionConfig,
|
|
27
|
-
*,
|
|
28
|
-
debug_log: DebugLog | None = None,
|
|
29
|
-
) -> str:
|
|
30
|
-
"""Resolve the API token for a connection.
|
|
31
|
-
|
|
32
|
-
Parameters
|
|
33
|
-
----------
|
|
34
|
-
conn:
|
|
35
|
-
The connection config whose auth sources to try.
|
|
36
|
-
debug_log:
|
|
37
|
-
Optional debug logger.
|
|
38
|
-
|
|
39
|
-
Returns
|
|
40
|
-
-------
|
|
41
|
-
str
|
|
42
|
-
The resolved token.
|
|
43
|
-
|
|
44
|
-
Raises
|
|
45
|
-
------
|
|
46
|
-
AuthError
|
|
47
|
-
If no token can be resolved from any source.
|
|
48
|
-
"""
|
|
49
|
-
if conn.token_cmd:
|
|
50
|
-
if debug_log:
|
|
51
|
-
debug_log.info("auth", f"running token_cmd: {conn.token_cmd}")
|
|
52
|
-
return _run_token_cmd(conn.token_cmd)
|
|
53
|
-
|
|
54
|
-
if conn.token_env:
|
|
55
|
-
if debug_log:
|
|
56
|
-
debug_log.info("auth", f"reading token from env var: {conn.token_env}")
|
|
57
|
-
value = os.environ.get(conn.token_env)
|
|
58
|
-
if value:
|
|
59
|
-
return value
|
|
60
|
-
raise AuthError(
|
|
61
|
-
f"Environment variable '{conn.token_env}' is not set. "
|
|
62
|
-
f"Export it or use 'token_cmd' instead."
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
raise AuthError(
|
|
66
|
-
"Connection has no auth source configured. "
|
|
67
|
-
"Set 'token_cmd' or 'token_env' in the connection config."
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def _run_token_cmd(cmd: str) -> str:
|
|
72
|
-
"""Execute a token command and return its stdout.
|
|
73
|
-
|
|
74
|
-
Raises :class:`AuthError` on non-zero exit or empty output.
|
|
75
|
-
"""
|
|
76
|
-
try:
|
|
77
|
-
proc = subprocess.run(
|
|
78
|
-
cmd,
|
|
79
|
-
shell=True, # noqa: S602 — user's own config, same trust as git credential.helper
|
|
80
|
-
capture_output=True,
|
|
81
|
-
text=True,
|
|
82
|
-
timeout=10,
|
|
83
|
-
)
|
|
84
|
-
except subprocess.TimeoutExpired:
|
|
85
|
-
raise AuthError(f"token_cmd timed out after 10s: {cmd}") from None
|
|
86
|
-
|
|
87
|
-
if proc.returncode != 0:
|
|
88
|
-
stderr = proc.stderr.strip()
|
|
89
|
-
detail = f": {stderr}" if stderr else ""
|
|
90
|
-
raise AuthError(f"token_cmd failed (exit {proc.returncode}){detail}: {cmd}")
|
|
91
|
-
|
|
92
|
-
token = proc.stdout.strip()
|
|
93
|
-
if not token:
|
|
94
|
-
raise AuthError(f"token_cmd returned empty output: {cmd}")
|
|
95
|
-
|
|
96
|
-
return token
|
|
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
|
|
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
|
|
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
|
|
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
|