assertion-cli 0.5.17__tar.gz → 0.5.18__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.
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/PKG-INFO +14 -9
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/README.md +13 -8
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/api.py +10 -6
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/PKG-INFO +14 -9
- assertion_cli-0.5.18/credentials.py +253 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/main.py +10 -5
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/pyproject.toml +1 -1
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/SKILL.md +1 -1
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_auth.py +5 -1
- assertion_cli-0.5.18/tests/test_credentials.py +189 -0
- assertion_cli-0.5.17/credentials.py +0 -120
- assertion_cli-0.5.17/tests/test_credentials.py +0 -122
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/SOURCES.txt +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/bundle.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/git.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/link.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/models.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/session.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/setup.cfg +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/AGENTS.md +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/templates/__init__.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_api.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_git.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_install.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_link.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_main.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_session.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_session_start.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_verify.py +0 -0
- {assertion_cli-0.5.17 → assertion_cli-0.5.18}/tests/test_verify_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.18
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
54
54
|
## Authentication
|
|
55
55
|
|
|
56
56
|
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
|
-
{
|
|
60
|
+
{
|
|
61
|
+
"version": 2,
|
|
62
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
63
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
64
|
+
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
68
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
-
workspace before saving
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
70
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
71
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
72
|
+
shows which token is in effect.
|
|
69
73
|
|
|
70
74
|
Token resolution at call time, in order:
|
|
71
75
|
|
|
72
|
-
1. the
|
|
73
|
-
2.
|
|
76
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
77
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
78
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
79
|
with no remote that `asrt login` won't store);
|
|
75
80
|
|
|
76
81
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -41,23 +41,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
41
41
|
## Authentication
|
|
42
42
|
|
|
43
43
|
`asrt login` stores your API token in a global, user-level file —
|
|
44
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
44
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
45
45
|
|
|
46
46
|
```json
|
|
47
|
-
{
|
|
47
|
+
{
|
|
48
|
+
"version": 2,
|
|
49
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
50
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
51
|
+
}
|
|
48
52
|
```
|
|
49
53
|
|
|
50
54
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
51
55
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
52
|
-
workspace before saving
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
57
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
58
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
59
|
+
shows which token is in effect.
|
|
56
60
|
|
|
57
61
|
Token resolution at call time, in order:
|
|
58
62
|
|
|
59
|
-
1. the
|
|
60
|
-
2.
|
|
63
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
64
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
65
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
61
66
|
with no remote that `asrt login` won't store);
|
|
62
67
|
|
|
63
68
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -9,7 +9,7 @@ import typer
|
|
|
9
9
|
from pydantic import BaseModel, ValidationError
|
|
10
10
|
|
|
11
11
|
from credentials import API_TOKEN_ENV, resolve_token
|
|
12
|
-
from git import find_git_root_or_none
|
|
12
|
+
from git import current_repo_full_name, find_git_root_or_none
|
|
13
13
|
from models import (
|
|
14
14
|
CheckpointResponse,
|
|
15
15
|
DecisionResponse,
|
|
@@ -35,11 +35,15 @@ def _base_url() -> str:
|
|
|
35
35
|
def _resolve_token() -> str | None:
|
|
36
36
|
"""Token for the current repo, else the shell env-var backup, else None.
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Stored credentials are primarily keyed by the current repo's GitHub
|
|
39
|
+
``owner/repo`` identity, with the legacy exact git-root path as fallback. A
|
|
40
|
+
real `export ASSERTION_API_TOKEN` only fills in when no stored entry matches.
|
|
41
41
|
"""
|
|
42
|
-
|
|
42
|
+
repo_root = find_git_root_or_none(Path.cwd())
|
|
43
|
+
repo_full_name = (
|
|
44
|
+
current_repo_full_name(repo_root) if repo_root is not None else None
|
|
45
|
+
)
|
|
46
|
+
token = resolve_token(repo_root, repo_full_name)
|
|
43
47
|
if token:
|
|
44
48
|
return token
|
|
45
49
|
env_token = (os.environ.get(API_TOKEN_ENV) or "").strip()
|
|
@@ -53,7 +57,7 @@ def _auth_headers() -> dict[str, str]:
|
|
|
53
57
|
where = f" for {repo_root}" if repo_root else ""
|
|
54
58
|
typer.echo(
|
|
55
59
|
f"No saved Assertion token{where}. Run `asrt login` to authenticate "
|
|
56
|
-
"this repo
|
|
60
|
+
"this repo.\n"
|
|
57
61
|
f"Alternatively, export {API_TOKEN_ENV} in your shell as a backup.",
|
|
58
62
|
err=True,
|
|
59
63
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.18
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
54
54
|
## Authentication
|
|
55
55
|
|
|
56
56
|
`asrt login` stores your API token in a global, user-level file —
|
|
57
|
-
`~/.assertion/credentials.json` (mode `0600`) —
|
|
57
|
+
`~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
|
-
{
|
|
60
|
+
{
|
|
61
|
+
"version": 2,
|
|
62
|
+
"tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
|
|
63
|
+
"repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
|
|
64
|
+
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
Run it from inside the repo it should authenticate. It validates the token and
|
|
64
68
|
confirms the repo's `origin` remote is connected to a stack in the token's
|
|
65
|
-
workspace before saving
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
workspace before saving the token against that workspace's stack repos, so each
|
|
70
|
+
repo can target a different workspace while moved folders and fresh clones keep
|
|
71
|
+
working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
|
|
72
|
+
shows which token is in effect.
|
|
69
73
|
|
|
70
74
|
Token resolution at call time, in order:
|
|
71
75
|
|
|
72
|
-
1. the
|
|
73
|
-
2.
|
|
76
|
+
1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
|
|
77
|
+
2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
|
|
78
|
+
3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
|
|
74
79
|
with no remote that `asrt login` won't store);
|
|
75
80
|
|
|
76
81
|
with a clear "run `asrt login`" error when neither is present. The API base URL
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Global, user-level token store at ``~/.assertion/credentials.json``.
|
|
2
|
+
|
|
3
|
+
The current store keeps plaintext tokens plus an inverted GitHub repo index, so
|
|
4
|
+
reclones or moved folders keep resolving as long as their ``origin`` still
|
|
5
|
+
points at a connected repo:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"version": 2,
|
|
9
|
+
"tokens": {"alk_...": ["acme/mvp"]},
|
|
10
|
+
"repo_index": {"acme/mvp": "alk_..."}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
The legacy v1 ``repos`` path map is still read as a fallback:
|
|
14
|
+
|
|
15
|
+
{"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
|
|
16
|
+
|
|
17
|
+
This module is deliberately git-free and pure stdlib — callers pass in the repo
|
|
18
|
+
root / repo full name (resolved via ``git.py``) and handle their own error
|
|
19
|
+
reporting. Token resolution precedence (repo entry first, then the env-var
|
|
20
|
+
backup) lives in ``api.py``; this module only owns on-disk reads and writes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
API_TOKEN_ENV = "ASSERTION_API_TOKEN"
|
|
30
|
+
|
|
31
|
+
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
32
|
+
CREDENTIALS_VERSION = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def assertion_home() -> Path:
|
|
36
|
+
"""Directory holding global Assertion credentials (``~/.assertion``)."""
|
|
37
|
+
return Path.home() / ".assertion"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def credentials_path() -> Path:
|
|
41
|
+
return assertion_home() / CREDENTIALS_FILE_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _empty_store() -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"version": CREDENTIALS_VERSION,
|
|
47
|
+
"tokens": {},
|
|
48
|
+
"repo_index": {},
|
|
49
|
+
"repos": {},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_store() -> dict:
|
|
54
|
+
"""Return the credentials store, tolerating a missing or corrupt file.
|
|
55
|
+
|
|
56
|
+
A malformed file is treated as empty rather than crashing every command —
|
|
57
|
+
the user can re-run ``asrt login`` to repair it.
|
|
58
|
+
"""
|
|
59
|
+
path = credentials_path()
|
|
60
|
+
if not path.exists():
|
|
61
|
+
return _empty_store()
|
|
62
|
+
try:
|
|
63
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
65
|
+
return _empty_store()
|
|
66
|
+
if not isinstance(data, dict):
|
|
67
|
+
return _empty_store()
|
|
68
|
+
data.setdefault("version", CREDENTIALS_VERSION)
|
|
69
|
+
if not isinstance(data.get("tokens"), dict):
|
|
70
|
+
data["tokens"] = {}
|
|
71
|
+
if not isinstance(data.get("repo_index"), dict):
|
|
72
|
+
data["repo_index"] = _repo_index_from_tokens(data["tokens"])
|
|
73
|
+
if not isinstance(data.get("repos"), dict):
|
|
74
|
+
data["repos"] = {}
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _repo_key(repo_root: Path) -> str:
|
|
79
|
+
"""Canonical store key for a repo root (absolute, symlinks resolved)."""
|
|
80
|
+
return str(repo_root.resolve())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _repo_name_key(repo_full_name: str) -> str:
|
|
84
|
+
return repo_full_name.strip().lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _normalize_repo_names(repo_full_names: list[str]) -> list[str]:
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
normalized: list[str] = []
|
|
90
|
+
for repo in repo_full_names:
|
|
91
|
+
key = _repo_name_key(repo)
|
|
92
|
+
if not key or key in seen:
|
|
93
|
+
continue
|
|
94
|
+
seen.add(key)
|
|
95
|
+
normalized.append(key)
|
|
96
|
+
return normalized
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _repo_index_from_tokens(tokens: dict) -> dict[str, str]:
|
|
100
|
+
index: dict[str, str] = {}
|
|
101
|
+
for token, repos in tokens.items():
|
|
102
|
+
if not isinstance(token, str) or not isinstance(repos, list):
|
|
103
|
+
continue
|
|
104
|
+
for repo in repos:
|
|
105
|
+
key = _repo_name_key(str(repo))
|
|
106
|
+
if key:
|
|
107
|
+
index[key] = token
|
|
108
|
+
return index
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _write_store(store: dict) -> Path:
|
|
112
|
+
home = assertion_home()
|
|
113
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
try:
|
|
115
|
+
home.chmod(0o700)
|
|
116
|
+
except OSError:
|
|
117
|
+
pass
|
|
118
|
+
path = credentials_path()
|
|
119
|
+
path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
|
|
120
|
+
try:
|
|
121
|
+
path.chmod(0o600)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
return path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_token(
|
|
128
|
+
token: str, *, repo_root: Path, repo_full_names: list[str] | None = None
|
|
129
|
+
) -> Path:
|
|
130
|
+
"""Persist ``token`` and return the credentials file path.
|
|
131
|
+
|
|
132
|
+
``repo_full_names`` are canonical GitHub ``owner/repo`` names visible to the
|
|
133
|
+
token. The legacy path entry is still written so older CLIs can read the
|
|
134
|
+
file during a rolling upgrade.
|
|
135
|
+
"""
|
|
136
|
+
store = load_store()
|
|
137
|
+
if repo_full_names is not None:
|
|
138
|
+
normalized = _normalize_repo_names(repo_full_names)
|
|
139
|
+
normalized_set = set(normalized)
|
|
140
|
+
repo_index = store["repo_index"]
|
|
141
|
+
for repo, indexed_token in list(repo_index.items()):
|
|
142
|
+
if indexed_token == token or repo in normalized_set:
|
|
143
|
+
del repo_index[repo]
|
|
144
|
+
for saved_token, repos in list(store["tokens"].items()):
|
|
145
|
+
if saved_token == token or not isinstance(repos, list):
|
|
146
|
+
continue
|
|
147
|
+
filtered = [
|
|
148
|
+
_repo_name_key(str(repo))
|
|
149
|
+
for repo in repos
|
|
150
|
+
if _repo_name_key(str(repo)) not in normalized_set
|
|
151
|
+
]
|
|
152
|
+
if filtered:
|
|
153
|
+
store["tokens"][saved_token] = filtered
|
|
154
|
+
else:
|
|
155
|
+
del store["tokens"][saved_token]
|
|
156
|
+
for repo in normalized:
|
|
157
|
+
if repo_index.get(repo) == saved_token:
|
|
158
|
+
del repo_index[repo]
|
|
159
|
+
store["tokens"][token] = normalized
|
|
160
|
+
for repo in normalized:
|
|
161
|
+
repo_index[repo] = token
|
|
162
|
+
store["repos"][_repo_key(repo_root)] = token
|
|
163
|
+
return _write_store(store)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def remove_token(*, repo_root: Path, repo_full_name: str | None = None) -> bool:
|
|
167
|
+
"""Delete this repo's saved credential. Return ``True`` if one was removed."""
|
|
168
|
+
store = load_store()
|
|
169
|
+
key = _repo_key(repo_root)
|
|
170
|
+
token = store["repos"].pop(key, None)
|
|
171
|
+
removed = token is not None
|
|
172
|
+
if repo_full_name is not None:
|
|
173
|
+
repo_key = _repo_name_key(repo_full_name)
|
|
174
|
+
indexed_token = store["repo_index"].pop(repo_key, None)
|
|
175
|
+
target_token = token or indexed_token
|
|
176
|
+
for saved_token, repos in list(store["tokens"].items()):
|
|
177
|
+
if target_token is not None and saved_token != target_token:
|
|
178
|
+
continue
|
|
179
|
+
if not isinstance(repos, list):
|
|
180
|
+
continue
|
|
181
|
+
filtered = [
|
|
182
|
+
str(repo).strip().lower()
|
|
183
|
+
for repo in repos
|
|
184
|
+
if _repo_name_key(str(repo)) != repo_key
|
|
185
|
+
]
|
|
186
|
+
if len(filtered) != len(repos):
|
|
187
|
+
removed = True
|
|
188
|
+
if filtered:
|
|
189
|
+
store["tokens"][saved_token] = filtered
|
|
190
|
+
else:
|
|
191
|
+
del store["tokens"][saved_token]
|
|
192
|
+
if saved_token == target_token:
|
|
193
|
+
break
|
|
194
|
+
removed = removed or indexed_token is not None
|
|
195
|
+
if token:
|
|
196
|
+
repo_refs = 0
|
|
197
|
+
for saved in store["repos"].values():
|
|
198
|
+
if saved == token:
|
|
199
|
+
repo_refs += 1
|
|
200
|
+
indexed_refs = [
|
|
201
|
+
indexed_token
|
|
202
|
+
for indexed_token in store["repo_index"].values()
|
|
203
|
+
if indexed_token == token
|
|
204
|
+
]
|
|
205
|
+
if (
|
|
206
|
+
repo_refs == 0
|
|
207
|
+
and not indexed_refs
|
|
208
|
+
and token in store["tokens"]
|
|
209
|
+
and repo_full_name is None
|
|
210
|
+
):
|
|
211
|
+
del store["tokens"][token]
|
|
212
|
+
if removed:
|
|
213
|
+
_write_store(store)
|
|
214
|
+
return removed
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def resolve_token(
|
|
218
|
+
repo_root: Path | None, repo_full_name: str | None = None
|
|
219
|
+
) -> str | None:
|
|
220
|
+
"""Return the stored token for ``repo_full_name`` / ``repo_root``.
|
|
221
|
+
|
|
222
|
+
Repo-name matching is primary and survives reclones/moved folders. The
|
|
223
|
+
legacy exact path match is retained so existing credentials keep working.
|
|
224
|
+
"""
|
|
225
|
+
store = load_store()
|
|
226
|
+
if repo_full_name is not None:
|
|
227
|
+
local = _repo_name_key(repo_full_name)
|
|
228
|
+
token = store["repo_index"].get(local)
|
|
229
|
+
if token:
|
|
230
|
+
return token
|
|
231
|
+
if repo_root is None:
|
|
232
|
+
return None
|
|
233
|
+
token = store["repos"].get(_repo_key(repo_root))
|
|
234
|
+
return token or None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def describe_source(
|
|
238
|
+
repo_root: Path | None, repo_full_name: str | None = None
|
|
239
|
+
) -> str | None:
|
|
240
|
+
"""Describe where the active token comes from, in resolution order.
|
|
241
|
+
|
|
242
|
+
Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
|
|
243
|
+
"""
|
|
244
|
+
if (
|
|
245
|
+
repo_full_name is not None
|
|
246
|
+
and resolve_token(repo_root, repo_full_name) is not None
|
|
247
|
+
):
|
|
248
|
+
return f"the saved token for {repo_full_name.strip().lower()}"
|
|
249
|
+
if repo_root is not None and resolve_token(repo_root) is not None:
|
|
250
|
+
return f"the saved token for {_repo_key(repo_root)}"
|
|
251
|
+
if (os.environ.get(API_TOKEN_ENV) or "").strip():
|
|
252
|
+
return f"the {API_TOKEN_ENV} environment variable"
|
|
253
|
+
return None
|
|
@@ -125,8 +125,9 @@ def login(
|
|
|
125
125
|
"""Authenticate this repo and save its token under ~/.assertion/.
|
|
126
126
|
|
|
127
127
|
Generate a token at Settings → Authentication in the dashboard, then run
|
|
128
|
-
this from inside
|
|
129
|
-
|
|
128
|
+
this from inside any repo it can access. The token is stored with the
|
|
129
|
+
workspace's stack repos, so moved folders and fresh clones keep working when
|
|
130
|
+
their `origin` remote matches.
|
|
130
131
|
|
|
131
132
|
Only repos whose `origin` remote matches a stack in the token's workspace
|
|
132
133
|
are stored; a repo with no remote can't be verified, so use the
|
|
@@ -173,7 +174,9 @@ def login(
|
|
|
173
174
|
"the dashboard, then re-run `asrt login`."
|
|
174
175
|
)
|
|
175
176
|
|
|
176
|
-
path = save_token(
|
|
177
|
+
path = save_token(
|
|
178
|
+
token, repo_root=repo_root, repo_full_names=[s.repo for s in stack_list]
|
|
179
|
+
)
|
|
177
180
|
names = ", ".join(f"'{s.name}'" for s in matched)
|
|
178
181
|
typer.echo(f"✓ {local} is connected to stack(s): {names}")
|
|
179
182
|
typer.echo(f"Saved token for {repo_root} to {path}")
|
|
@@ -186,7 +189,8 @@ def logout() -> None:
|
|
|
186
189
|
if repo_root is None:
|
|
187
190
|
exit_with_error("Not inside a git repository.")
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
local = current_repo_full_name(repo_root)
|
|
193
|
+
if remove_token(repo_root=repo_root, repo_full_name=local):
|
|
190
194
|
typer.echo(f"Removed saved token for {repo_root}.")
|
|
191
195
|
else:
|
|
192
196
|
typer.echo(f"No saved token for {repo_root}.")
|
|
@@ -196,7 +200,8 @@ def logout() -> None:
|
|
|
196
200
|
def whoami() -> None:
|
|
197
201
|
"""Show which token would be used here and confirm it works."""
|
|
198
202
|
repo_root = find_git_root_or_none(Path.cwd())
|
|
199
|
-
|
|
203
|
+
local = current_repo_full_name(repo_root) if repo_root is not None else None
|
|
204
|
+
source = describe_source(repo_root, local)
|
|
200
205
|
if source is None:
|
|
201
206
|
exit_with_error(
|
|
202
207
|
"Not authenticated. Run `asrt login` to save a token for this repo."
|
|
@@ -21,7 +21,7 @@ Before any `asrt` call:
|
|
|
21
21
|
|
|
22
22
|
- You are inside a git repository.
|
|
23
23
|
- `asrt install` has been run once in this repo. It installs the skill files you are reading right now (plus the `AGENTS.md` / `CLAUDE.md` pointer and `.gitignore` rules). This is an onboarding step, not a per-run one.
|
|
24
|
-
- `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by repo
|
|
24
|
+
- `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by GitHub `owner/repo`) — not in a repo `.env`. If an `asrt` call fails with "No saved Assertion token … run `asrt login`", the user needs to run `asrt login` (or export `ASSERTION_API_TOKEN`); surface that to them rather than retrying.
|
|
25
25
|
- `asrt session start` has been run for this work session. It resets `.assertion/` (except `.assertion/seed.py`) and records the verification base — the merge-base with the upstream tracking branch by default, or an explicit `--from <ref>`. If `asrt checkpoint` or `asrt verify` errors with "No verification base recorded", run `asrt session start` and retry.
|
|
26
26
|
|
|
27
27
|
**First command sequence for a new work session:**
|
|
@@ -43,7 +43,9 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
|
43
43
|
|
|
44
44
|
@respx.mock
|
|
45
45
|
def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
|
|
46
|
-
route = respx.get(STACKS_URL).mock(
|
|
46
|
+
route = respx.get(STACKS_URL).mock(
|
|
47
|
+
return_value=_stacks_response("acme/mvp", "acme/docs")
|
|
48
|
+
)
|
|
47
49
|
|
|
48
50
|
result = runner.invoke(main.app, ["login", "--token", "alk_new"])
|
|
49
51
|
|
|
@@ -51,6 +53,8 @@ def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
|
|
|
51
53
|
assert "connected to stack" in result.output.lower()
|
|
52
54
|
assert route.called
|
|
53
55
|
assert credentials.resolve_token(repo) == "alk_new"
|
|
56
|
+
assert credentials.resolve_token(None, "acme/mvp") == "alk_new"
|
|
57
|
+
assert credentials.resolve_token(None, "acme/docs") == "alk_new"
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
@respx.mock
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import credentials
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(autouse=True)
|
|
13
|
+
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
14
|
+
"""Point the store at a throwaway dir and clear the env-var backup."""
|
|
15
|
+
home = tmp_path / "home"
|
|
16
|
+
monkeypatch.setattr(credentials, "assertion_home", lambda: home)
|
|
17
|
+
monkeypatch.delenv(credentials.API_TOKEN_ENV, raising=False)
|
|
18
|
+
return home
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def repo(tmp_path: Path) -> Path:
|
|
23
|
+
root = tmp_path / "repo"
|
|
24
|
+
root.mkdir()
|
|
25
|
+
return root
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_save_and_resolve_round_trip(repo: Path) -> None:
|
|
29
|
+
credentials.save_token(
|
|
30
|
+
"alk_one", repo_root=repo, repo_full_names=["acme/mvp", "Acme/Docs"]
|
|
31
|
+
)
|
|
32
|
+
assert credentials.resolve_token(repo, "ACME/MVP") == "alk_one"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_resolve_unknown_repo_returns_none(repo: Path, tmp_path: Path) -> None:
|
|
36
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
37
|
+
other = tmp_path / "other"
|
|
38
|
+
other.mkdir()
|
|
39
|
+
assert credentials.resolve_token(other) is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_resolve_none_repo_root() -> None:
|
|
43
|
+
assert credentials.resolve_token(None) is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_save_writes_file_mode_0600(repo: Path) -> None:
|
|
47
|
+
path = credentials.save_token("alk_one", repo_root=repo)
|
|
48
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
49
|
+
assert mode == 0o600
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_store_shape(repo: Path) -> None:
|
|
53
|
+
path = credentials.save_token(
|
|
54
|
+
"alk_one", repo_root=repo, repo_full_names=["Acme/MVP", "acme/mvp"]
|
|
55
|
+
)
|
|
56
|
+
data = json.loads(path.read_text())
|
|
57
|
+
assert data["version"] == credentials.CREDENTIALS_VERSION
|
|
58
|
+
assert data["tokens"]["alk_one"] == ["acme/mvp"]
|
|
59
|
+
assert data["repo_index"]["acme/mvp"] == "alk_one"
|
|
60
|
+
assert data["repos"][str(repo.resolve())] == "alk_one"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_save_is_idempotent_overwrite(repo: Path) -> None:
|
|
64
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
65
|
+
credentials.save_token("alk_two", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
66
|
+
assert credentials.resolve_token(repo, "acme/mvp") == "alk_two"
|
|
67
|
+
store = credentials.load_store()
|
|
68
|
+
assert len(store["repos"]) == 1
|
|
69
|
+
assert len(store["tokens"]) == 1
|
|
70
|
+
assert store["repo_index"] == {"acme/mvp": "alk_two"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_distinct_repos_get_distinct_entries(tmp_path: Path) -> None:
|
|
74
|
+
a, b = tmp_path / "a", tmp_path / "b"
|
|
75
|
+
a.mkdir()
|
|
76
|
+
b.mkdir()
|
|
77
|
+
credentials.save_token("alk_a", repo_root=a, repo_full_names=["acme/a"])
|
|
78
|
+
credentials.save_token("alk_b", repo_root=b, repo_full_names=["acme/b"])
|
|
79
|
+
assert credentials.resolve_token(a, "acme/a") == "alk_a"
|
|
80
|
+
assert credentials.resolve_token(b, "acme/b") == "alk_b"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_load_store_missing_file_is_empty() -> None:
|
|
84
|
+
assert credentials.load_store() == {
|
|
85
|
+
"version": credentials.CREDENTIALS_VERSION,
|
|
86
|
+
"tokens": {},
|
|
87
|
+
"repo_index": {},
|
|
88
|
+
"repos": {},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_load_store_tolerates_corrupt_file(_isolate_home: Path) -> None:
|
|
93
|
+
path = credentials.credentials_path()
|
|
94
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
path.write_text("{ not json", encoding="utf-8")
|
|
96
|
+
assert credentials.load_store() == {
|
|
97
|
+
"version": credentials.CREDENTIALS_VERSION,
|
|
98
|
+
"tokens": {},
|
|
99
|
+
"repo_index": {},
|
|
100
|
+
"repos": {},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_remove_token(repo: Path) -> None:
|
|
105
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
106
|
+
assert credentials.remove_token(repo_root=repo, repo_full_name="acme/mvp") is True
|
|
107
|
+
assert credentials.resolve_token(repo, "acme/mvp") is None
|
|
108
|
+
# Removing again is a no-op.
|
|
109
|
+
assert credentials.remove_token(repo_root=repo) is False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_describe_source_repo_entry(repo: Path) -> None:
|
|
113
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
114
|
+
assert credentials.describe_source(repo, "acme/mvp") == (
|
|
115
|
+
"the saved token for acme/mvp"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_describe_source_env_var(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
120
|
+
monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
|
|
121
|
+
# No stored entry for this repo -> falls through to the env-var source.
|
|
122
|
+
assert credentials.describe_source(repo) == (
|
|
123
|
+
f"the {credentials.API_TOKEN_ENV} environment variable"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_describe_source_repo_entry_wins_over_env(
|
|
128
|
+
repo: Path, monkeypatch: pytest.MonkeyPatch
|
|
129
|
+
) -> None:
|
|
130
|
+
monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
|
|
131
|
+
credentials.save_token("alk_one", repo_root=repo)
|
|
132
|
+
assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_describe_source_none(repo: Path) -> None:
|
|
136
|
+
assert credentials.describe_source(repo) is None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_resolve_legacy_path_store(repo: Path) -> None:
|
|
140
|
+
path = credentials.credentials_path()
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
path.write_text(
|
|
143
|
+
json.dumps({"version": 1, "repos": {str(repo.resolve()): "alk_old"}}),
|
|
144
|
+
encoding="utf-8",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
assert credentials.resolve_token(repo, "acme/mvp") == "alk_old"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_load_store_builds_repo_index_from_token_only_v2_store() -> None:
|
|
151
|
+
path = credentials.credentials_path()
|
|
152
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
path.write_text(
|
|
154
|
+
json.dumps({"version": 2, "tokens": {"alk_one": ["Acme/MVP"]}}),
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert credentials.load_store()["repo_index"] == {"acme/mvp": "alk_one"}
|
|
159
|
+
assert credentials.resolve_token(None, "acme/mvp") == "alk_one"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_resolve_repo_name_after_reclone(repo: Path, tmp_path: Path) -> None:
|
|
163
|
+
moved = tmp_path / "moved"
|
|
164
|
+
moved.mkdir()
|
|
165
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
166
|
+
|
|
167
|
+
assert credentials.resolve_token(moved, "acme/mvp") == "alk_one"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_remove_token_keeps_other_repo_names(repo: Path) -> None:
|
|
171
|
+
credentials.save_token(
|
|
172
|
+
"alk_one", repo_root=repo, repo_full_names=["acme/mvp", "acme/docs"]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
assert credentials.remove_token(repo_root=repo, repo_full_name="acme/mvp") is True
|
|
176
|
+
assert credentials.resolve_token(None, "acme/mvp") is None
|
|
177
|
+
assert credentials.resolve_token(None, "acme/docs") == "alk_one"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_resaving_same_token_removes_stale_repo_index_entries(repo: Path) -> None:
|
|
181
|
+
credentials.save_token(
|
|
182
|
+
"alk_one", repo_root=repo, repo_full_names=["acme/mvp", "acme/docs"]
|
|
183
|
+
)
|
|
184
|
+
credentials.save_token("alk_one", repo_root=repo, repo_full_names=["acme/mvp"])
|
|
185
|
+
|
|
186
|
+
store = credentials.load_store()
|
|
187
|
+
assert store["tokens"]["alk_one"] == ["acme/mvp"]
|
|
188
|
+
assert store["repo_index"] == {"acme/mvp": "alk_one"}
|
|
189
|
+
assert credentials.resolve_token(None, "acme/docs") is None
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
"""Global, user-level token store at ``~/.assertion/credentials.json``.
|
|
2
|
-
|
|
3
|
-
The store is a map of canonicalized git-root path -> API token, so each repo
|
|
4
|
-
resolves to its own workspace token:
|
|
5
|
-
|
|
6
|
-
{"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
|
|
7
|
-
|
|
8
|
-
This module is deliberately git-free and pure stdlib — callers pass in the repo
|
|
9
|
-
root (resolved via ``git.py``) and handle their own error reporting. Token
|
|
10
|
-
resolution precedence (repo entry first, then the env-var backup) lives in
|
|
11
|
-
``api.py``; this module only owns on-disk reads and writes.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
from pathlib import Path
|
|
19
|
-
|
|
20
|
-
API_TOKEN_ENV = "ASSERTION_API_TOKEN"
|
|
21
|
-
|
|
22
|
-
CREDENTIALS_FILE_NAME = "credentials.json"
|
|
23
|
-
CREDENTIALS_VERSION = 1
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def assertion_home() -> Path:
|
|
27
|
-
"""Directory holding global Assertion credentials (``~/.assertion``)."""
|
|
28
|
-
return Path.home() / ".assertion"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def credentials_path() -> Path:
|
|
32
|
-
return assertion_home() / CREDENTIALS_FILE_NAME
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _empty_store() -> dict:
|
|
36
|
-
return {"version": CREDENTIALS_VERSION, "repos": {}}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def load_store() -> dict:
|
|
40
|
-
"""Return the credentials store, tolerating a missing or corrupt file.
|
|
41
|
-
|
|
42
|
-
A malformed file is treated as empty rather than crashing every command —
|
|
43
|
-
the user can re-run ``asrt login`` to repair it.
|
|
44
|
-
"""
|
|
45
|
-
path = credentials_path()
|
|
46
|
-
if not path.exists():
|
|
47
|
-
return _empty_store()
|
|
48
|
-
try:
|
|
49
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
-
except (json.JSONDecodeError, OSError, ValueError):
|
|
51
|
-
return _empty_store()
|
|
52
|
-
if not isinstance(data, dict):
|
|
53
|
-
return _empty_store()
|
|
54
|
-
data.setdefault("version", CREDENTIALS_VERSION)
|
|
55
|
-
if not isinstance(data.get("repos"), dict):
|
|
56
|
-
data["repos"] = {}
|
|
57
|
-
return data
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _repo_key(repo_root: Path) -> str:
|
|
61
|
-
"""Canonical store key for a repo root (absolute, symlinks resolved)."""
|
|
62
|
-
return str(repo_root.resolve())
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _write_store(store: dict) -> Path:
|
|
66
|
-
home = assertion_home()
|
|
67
|
-
home.mkdir(parents=True, exist_ok=True)
|
|
68
|
-
try:
|
|
69
|
-
home.chmod(0o700)
|
|
70
|
-
except OSError:
|
|
71
|
-
pass
|
|
72
|
-
path = credentials_path()
|
|
73
|
-
path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
|
|
74
|
-
try:
|
|
75
|
-
path.chmod(0o600)
|
|
76
|
-
except OSError:
|
|
77
|
-
pass
|
|
78
|
-
return path
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def save_token(token: str, *, repo_root: Path) -> Path:
|
|
82
|
-
"""Persist ``token`` for ``repo_root`` and return the credentials file path."""
|
|
83
|
-
store = load_store()
|
|
84
|
-
store["repos"][_repo_key(repo_root)] = token
|
|
85
|
-
return _write_store(store)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def remove_token(*, repo_root: Path) -> bool:
|
|
89
|
-
"""Delete the entry for ``repo_root``. Return ``True`` if one was removed."""
|
|
90
|
-
store = load_store()
|
|
91
|
-
key = _repo_key(repo_root)
|
|
92
|
-
if key in store["repos"]:
|
|
93
|
-
del store["repos"][key]
|
|
94
|
-
_write_store(store)
|
|
95
|
-
return True
|
|
96
|
-
return False
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def resolve_token(repo_root: Path | None) -> str | None:
|
|
100
|
-
"""Return the stored token for ``repo_root`` (exact path match), else ``None``.
|
|
101
|
-
|
|
102
|
-
No fuzzy or parent-path matching: a moved or never-authenticated repo simply
|
|
103
|
-
misses, and the caller falls back to the env-var backup.
|
|
104
|
-
"""
|
|
105
|
-
if repo_root is None:
|
|
106
|
-
return None
|
|
107
|
-
token = load_store()["repos"].get(_repo_key(repo_root))
|
|
108
|
-
return token or None
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def describe_source(repo_root: Path | None) -> str | None:
|
|
112
|
-
"""Describe where the active token comes from, in resolution order.
|
|
113
|
-
|
|
114
|
-
Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
|
|
115
|
-
"""
|
|
116
|
-
if repo_root is not None and resolve_token(repo_root) is not None:
|
|
117
|
-
return f"the saved token for {_repo_key(repo_root)}"
|
|
118
|
-
if (os.environ.get(API_TOKEN_ENV) or "").strip():
|
|
119
|
-
return f"the {API_TOKEN_ENV} environment variable"
|
|
120
|
-
return None
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import stat
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
import credentials
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
@pytest.fixture(autouse=True)
|
|
13
|
-
def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
|
14
|
-
"""Point the store at a throwaway dir and clear the env-var backup."""
|
|
15
|
-
home = tmp_path / "home"
|
|
16
|
-
monkeypatch.setattr(credentials, "assertion_home", lambda: home)
|
|
17
|
-
monkeypatch.delenv(credentials.API_TOKEN_ENV, raising=False)
|
|
18
|
-
return home
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@pytest.fixture
|
|
22
|
-
def repo(tmp_path: Path) -> Path:
|
|
23
|
-
root = tmp_path / "repo"
|
|
24
|
-
root.mkdir()
|
|
25
|
-
return root
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_save_and_resolve_round_trip(repo: Path) -> None:
|
|
29
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
30
|
-
assert credentials.resolve_token(repo) == "alk_one"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def test_resolve_unknown_repo_returns_none(repo: Path, tmp_path: Path) -> None:
|
|
34
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
35
|
-
other = tmp_path / "other"
|
|
36
|
-
other.mkdir()
|
|
37
|
-
assert credentials.resolve_token(other) is None
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_resolve_none_repo_root() -> None:
|
|
41
|
-
assert credentials.resolve_token(None) is None
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_save_writes_file_mode_0600(repo: Path) -> None:
|
|
45
|
-
path = credentials.save_token("alk_one", repo_root=repo)
|
|
46
|
-
mode = stat.S_IMODE(path.stat().st_mode)
|
|
47
|
-
assert mode == 0o600
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_store_shape(repo: Path) -> None:
|
|
51
|
-
path = credentials.save_token("alk_one", repo_root=repo)
|
|
52
|
-
data = json.loads(path.read_text())
|
|
53
|
-
assert data["version"] == credentials.CREDENTIALS_VERSION
|
|
54
|
-
assert data["repos"][str(repo.resolve())] == "alk_one"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_save_is_idempotent_overwrite(repo: Path) -> None:
|
|
58
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
59
|
-
credentials.save_token("alk_two", repo_root=repo)
|
|
60
|
-
assert credentials.resolve_token(repo) == "alk_two"
|
|
61
|
-
store = credentials.load_store()
|
|
62
|
-
assert len(store["repos"]) == 1
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def test_distinct_repos_get_distinct_entries(tmp_path: Path) -> None:
|
|
66
|
-
a, b = tmp_path / "a", tmp_path / "b"
|
|
67
|
-
a.mkdir()
|
|
68
|
-
b.mkdir()
|
|
69
|
-
credentials.save_token("alk_a", repo_root=a)
|
|
70
|
-
credentials.save_token("alk_b", repo_root=b)
|
|
71
|
-
assert credentials.resolve_token(a) == "alk_a"
|
|
72
|
-
assert credentials.resolve_token(b) == "alk_b"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def test_load_store_missing_file_is_empty() -> None:
|
|
76
|
-
assert credentials.load_store() == {
|
|
77
|
-
"version": credentials.CREDENTIALS_VERSION,
|
|
78
|
-
"repos": {},
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_load_store_tolerates_corrupt_file(_isolate_home: Path) -> None:
|
|
83
|
-
path = credentials.credentials_path()
|
|
84
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
-
path.write_text("{ not json", encoding="utf-8")
|
|
86
|
-
assert credentials.load_store() == {
|
|
87
|
-
"version": credentials.CREDENTIALS_VERSION,
|
|
88
|
-
"repos": {},
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def test_remove_token(repo: Path) -> None:
|
|
93
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
94
|
-
assert credentials.remove_token(repo_root=repo) is True
|
|
95
|
-
assert credentials.resolve_token(repo) is None
|
|
96
|
-
# Removing again is a no-op.
|
|
97
|
-
assert credentials.remove_token(repo_root=repo) is False
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def test_describe_source_repo_entry(repo: Path) -> None:
|
|
101
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
102
|
-
assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def test_describe_source_env_var(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
106
|
-
monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
|
|
107
|
-
# No stored entry for this repo -> falls through to the env-var source.
|
|
108
|
-
assert credentials.describe_source(repo) == (
|
|
109
|
-
f"the {credentials.API_TOKEN_ENV} environment variable"
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_describe_source_repo_entry_wins_over_env(
|
|
114
|
-
repo: Path, monkeypatch: pytest.MonkeyPatch
|
|
115
|
-
) -> None:
|
|
116
|
-
monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
|
|
117
|
-
credentials.save_token("alk_one", repo_root=repo)
|
|
118
|
-
assert credentials.describe_source(repo) == f"the saved token for {repo.resolve()}"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def test_describe_source_none(repo: Path) -> None:
|
|
122
|
-
assert credentials.describe_source(repo) is None
|
|
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
|