assertion-cli 0.5.17__tar.gz → 0.5.19__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.
Files changed (37) hide show
  1. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/PKG-INFO +12 -8
  2. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/README.md +11 -7
  3. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/api.py +10 -6
  4. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/PKG-INFO +12 -8
  5. assertion_cli-0.5.19/credentials.py +214 -0
  6. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/main.py +8 -5
  7. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/templates/SKILL.md +1 -1
  9. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_auth.py +8 -4
  10. assertion_cli-0.5.19/tests/test_credentials.py +186 -0
  11. assertion_cli-0.5.17/credentials.py +0 -120
  12. assertion_cli-0.5.17/tests/test_credentials.py +0 -122
  13. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/SOURCES.txt +0 -0
  14. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/dependency_links.txt +0 -0
  15. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/entry_points.txt +0 -0
  16. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/requires.txt +0 -0
  17. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/assertion_cli.egg-info/top_level.txt +0 -0
  18. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/bundle.py +0 -0
  19. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/git.py +0 -0
  20. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/link.py +0 -0
  21. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/models.py +0 -0
  22. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/session.py +0 -0
  23. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/setup.cfg +0 -0
  24. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/templates/AGENTS.md +0 -0
  25. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/templates/__init__.py +0 -0
  26. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_api.py +0 -0
  27. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_bundle.py +0 -0
  28. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_decision.py +0 -0
  29. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_git.py +0 -0
  30. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_install.py +0 -0
  31. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_link.py +0 -0
  32. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_main.py +0 -0
  33. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_prompt.py +0 -0
  34. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_session.py +0 -0
  35. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_session_start.py +0 -0
  36. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/tests/test_verify.py +0 -0
  37. {assertion_cli-0.5.17 → assertion_cli-0.5.19}/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.17
3
+ Version: 0.5.19
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -54,22 +54,26 @@ 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`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, so each repo can target a different workspace. Because
66
- entries are keyed by path, moving or renaming the repo folder means logging in
67
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
68
- `asrt whoami` shows which token is in effect.
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 entry for the current repo path in `~/.assertion/credentials.json`;
76
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
73
77
  2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
74
78
  with no remote that `asrt login` won't store);
75
79
 
@@ -41,22 +41,26 @@ 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`) — keyed by the repo's path:
44
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
45
45
 
46
46
  ```json
47
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, so each repo can target a different workspace. Because
53
- entries are keyed by path, moving or renaming the repo folder means logging in
54
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
55
- `asrt whoami` shows which token is in effect.
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 entry for the current repo path in `~/.assertion/credentials.json`;
63
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
60
64
  2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
61
65
  with no remote that `asrt login` won't store);
62
66
 
@@ -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
- The stored credential (keyed by the exact current git root) is primary; a
39
- real `export ASSERTION_API_TOKEN` only fills in when no entry matches — e.g.
40
- in CI, or a repo that was moved/renamed and never re-authenticated.
38
+ Stored credentials are keyed by the current repo's GitHub ``owner/repo``
39
+ identity. A real `export ASSERTION_API_TOKEN` only fills in when no stored
40
+ entry matches.
41
41
  """
42
- token = resolve_token(find_git_root_or_none(Path.cwd()))
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 (if you moved or renamed the folder, log in again).\n"
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.17
3
+ Version: 0.5.19
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -54,22 +54,26 @@ 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`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
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, so each repo can target a different workspace. Because
66
- entries are keyed by path, moving or renaming the repo folder means logging in
67
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
68
- `asrt whoami` shows which token is in effect.
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 entry for the current repo path in `~/.assertion/credentials.json`;
76
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
73
77
  2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
74
78
  with no remote that `asrt login` won't store);
75
79
 
@@ -0,0 +1,214 @@
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
+ This module is deliberately git-free and pure stdlib — callers pass in the repo
14
+ full name (resolved via ``git.py``) and handle their own error reporting. Token
15
+ resolution precedence (repo entry first, then the env-var backup) lives in
16
+ ``api.py``; this module only owns on-disk reads and writes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+
25
+ API_TOKEN_ENV = "ASSERTION_API_TOKEN"
26
+
27
+ CREDENTIALS_FILE_NAME = "credentials.json"
28
+ CREDENTIALS_VERSION = 2
29
+
30
+
31
+ def assertion_home() -> Path:
32
+ """Directory holding global Assertion credentials (``~/.assertion``)."""
33
+ return Path.home() / ".assertion"
34
+
35
+
36
+ def credentials_path() -> Path:
37
+ return assertion_home() / CREDENTIALS_FILE_NAME
38
+
39
+
40
+ def _empty_store() -> dict:
41
+ return {
42
+ "version": CREDENTIALS_VERSION,
43
+ "tokens": {},
44
+ "repo_index": {},
45
+ }
46
+
47
+
48
+ def load_store() -> dict:
49
+ """Return the credentials store, tolerating a missing or corrupt file.
50
+
51
+ A malformed file is treated as empty rather than crashing every command —
52
+ the user can re-run ``asrt login`` to repair it.
53
+ """
54
+ path = credentials_path()
55
+ if not path.exists():
56
+ return _empty_store()
57
+ try:
58
+ data = json.loads(path.read_text(encoding="utf-8"))
59
+ except (json.JSONDecodeError, OSError, ValueError):
60
+ return _empty_store()
61
+ if not isinstance(data, dict):
62
+ return _empty_store()
63
+ data.setdefault("version", CREDENTIALS_VERSION)
64
+ if not isinstance(data.get("tokens"), dict):
65
+ data["tokens"] = {}
66
+ if not isinstance(data.get("repo_index"), dict):
67
+ data["repo_index"] = _repo_index_from_tokens(data["tokens"])
68
+ data.pop("repos", None)
69
+ return data
70
+
71
+
72
+ def _repo_name_key(repo_full_name: str) -> str:
73
+ return repo_full_name.strip().lower()
74
+
75
+
76
+ def _normalize_repo_names(repo_full_names: list[str]) -> list[str]:
77
+ seen: set[str] = set()
78
+ normalized: list[str] = []
79
+ for repo in repo_full_names:
80
+ key = _repo_name_key(repo)
81
+ if not key or key in seen:
82
+ continue
83
+ seen.add(key)
84
+ normalized.append(key)
85
+ return normalized
86
+
87
+
88
+ def _repo_index_from_tokens(tokens: dict) -> dict[str, str]:
89
+ index: dict[str, str] = {}
90
+ for token, repos in tokens.items():
91
+ if not isinstance(token, str) or not isinstance(repos, list):
92
+ continue
93
+ for repo in repos:
94
+ key = _repo_name_key(str(repo))
95
+ if key:
96
+ index[key] = token
97
+ return index
98
+
99
+
100
+ def _write_store(store: dict) -> Path:
101
+ home = assertion_home()
102
+ home.mkdir(parents=True, exist_ok=True)
103
+ try:
104
+ home.chmod(0o700)
105
+ except OSError:
106
+ pass
107
+ path = credentials_path()
108
+ path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
109
+ try:
110
+ path.chmod(0o600)
111
+ except OSError:
112
+ pass
113
+ return path
114
+
115
+
116
+ def save_token(token: str, *, repo_full_names: list[str] | None = None) -> Path:
117
+ """Persist ``token`` and return the credentials file path.
118
+
119
+ ``repo_full_names`` are canonical GitHub ``owner/repo`` names visible to the
120
+ token.
121
+ """
122
+ store = load_store()
123
+ if repo_full_names is not None:
124
+ normalized = _normalize_repo_names(repo_full_names)
125
+ normalized_set = set(normalized)
126
+ repo_index = store["repo_index"]
127
+ for repo, indexed_token in list(repo_index.items()):
128
+ if indexed_token == token or repo in normalized_set:
129
+ del repo_index[repo]
130
+ for saved_token, repos in list(store["tokens"].items()):
131
+ if saved_token == token or not isinstance(repos, list):
132
+ continue
133
+ filtered = [
134
+ _repo_name_key(str(repo))
135
+ for repo in repos
136
+ if _repo_name_key(str(repo)) not in normalized_set
137
+ ]
138
+ if filtered:
139
+ store["tokens"][saved_token] = filtered
140
+ else:
141
+ del store["tokens"][saved_token]
142
+ for repo in normalized:
143
+ if repo_index.get(repo) == saved_token:
144
+ del repo_index[repo]
145
+ store["tokens"][token] = normalized
146
+ for repo in normalized:
147
+ repo_index[repo] = token
148
+ return _write_store(store)
149
+
150
+
151
+ def remove_token(*, repo_full_name: str | None = None) -> bool:
152
+ """Delete this repo's saved credential. Return ``True`` if one was removed."""
153
+ store = load_store()
154
+ removed = False
155
+ if repo_full_name is not None:
156
+ repo_key = _repo_name_key(repo_full_name)
157
+ indexed_token = store["repo_index"].pop(repo_key, None)
158
+ target_token = indexed_token
159
+ for saved_token, repos in list(store["tokens"].items()):
160
+ if target_token is not None and saved_token != target_token:
161
+ continue
162
+ if not isinstance(repos, list):
163
+ continue
164
+ filtered = [
165
+ str(repo).strip().lower()
166
+ for repo in repos
167
+ if _repo_name_key(str(repo)) != repo_key
168
+ ]
169
+ if len(filtered) != len(repos):
170
+ removed = True
171
+ if filtered:
172
+ store["tokens"][saved_token] = filtered
173
+ else:
174
+ del store["tokens"][saved_token]
175
+ if saved_token == target_token:
176
+ break
177
+ removed = removed or indexed_token is not None
178
+ if removed:
179
+ _write_store(store)
180
+ return removed
181
+
182
+
183
+ def resolve_token(
184
+ repo_root: Path | None, repo_full_name: str | None = None
185
+ ) -> str | None:
186
+ """Return the stored token for ``repo_full_name`` / ``repo_root``.
187
+
188
+ Repo-name matching survives reclones/moved folders because it uses the
189
+ checkout's GitHub ``owner/repo`` identity instead of the local path.
190
+ """
191
+ store = load_store()
192
+ if repo_full_name is not None:
193
+ local = _repo_name_key(repo_full_name)
194
+ token = store["repo_index"].get(local)
195
+ if token:
196
+ return token
197
+ return None
198
+
199
+
200
+ def describe_source(
201
+ repo_root: Path | None, repo_full_name: str | None = None
202
+ ) -> str | None:
203
+ """Describe where the active token comes from, in resolution order.
204
+
205
+ Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
206
+ """
207
+ if (
208
+ repo_full_name is not None
209
+ and resolve_token(repo_root, repo_full_name) is not None
210
+ ):
211
+ return f"the saved token for {repo_full_name.strip().lower()}"
212
+ if (os.environ.get(API_TOKEN_ENV) or "").strip():
213
+ return f"the {API_TOKEN_ENV} environment variable"
214
+ 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 the repo it should be used for. The token is stored keyed
129
- by the repo's path, so each repo can target a different workspace.
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,7 @@ def login(
173
174
  "the dashboard, then re-run `asrt login`."
174
175
  )
175
176
 
176
- path = save_token(token, repo_root=repo_root)
177
+ path = save_token(token, repo_full_names=[s.repo for s in stack_list])
177
178
  names = ", ".join(f"'{s.name}'" for s in matched)
178
179
  typer.echo(f"✓ {local} is connected to stack(s): {names}")
179
180
  typer.echo(f"Saved token for {repo_root} to {path}")
@@ -186,7 +187,8 @@ def logout() -> None:
186
187
  if repo_root is None:
187
188
  exit_with_error("Not inside a git repository.")
188
189
 
189
- if remove_token(repo_root=repo_root):
190
+ local = current_repo_full_name(repo_root)
191
+ if remove_token(repo_full_name=local):
190
192
  typer.echo(f"Removed saved token for {repo_root}.")
191
193
  else:
192
194
  typer.echo(f"No saved token for {repo_root}.")
@@ -196,7 +198,8 @@ def logout() -> None:
196
198
  def whoami() -> None:
197
199
  """Show which token would be used here and confirm it works."""
198
200
  repo_root = find_git_root_or_none(Path.cwd())
199
- source = describe_source(repo_root)
201
+ local = current_repo_full_name(repo_root) if repo_root is not None else None
202
+ source = describe_source(repo_root, local)
200
203
  if source is None:
201
204
  exit_with_error(
202
205
  "Not authenticated. Run `asrt login` to save a token for this repo."
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.17"
7
+ version = "0.5.19"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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 path) — 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.
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,14 +43,18 @@ 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(return_value=_stacks_response("acme/mvp"))
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
 
50
52
  assert result.exit_code == 0, result.output
51
53
  assert "connected to stack" in result.output.lower()
52
54
  assert route.called
53
- assert credentials.resolve_token(repo) == "alk_new"
55
+ assert credentials.resolve_token(repo, "acme/mvp") == "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
@@ -92,7 +96,7 @@ def test_login_no_remote_does_not_save_or_call_network(
92
96
 
93
97
 
94
98
  def test_logout_removes_token(repo: Path) -> None:
95
- credentials.save_token("alk_new", repo_root=repo)
99
+ credentials.save_token("alk_new", repo_full_names=["acme/mvp"])
96
100
 
97
101
  result = runner.invoke(main.app, ["logout"])
98
102
 
@@ -110,7 +114,7 @@ def test_logout_when_nothing_saved(repo: Path) -> None:
110
114
 
111
115
  @respx.mock
112
116
  def test_whoami_reports_saved_token(repo: Path) -> None:
113
- credentials.save_token("alk_new", repo_root=repo)
117
+ credentials.save_token("alk_new", repo_full_names=["acme/mvp"])
114
118
  respx.get(STACKS_URL).mock(return_value=_stacks_response("acme/mvp", "acme/other"))
115
119
 
116
120
  result = runner.invoke(main.app, ["whoami"])
@@ -0,0 +1,186 @@
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_full_names=["acme/mvp", "Acme/Docs"])
30
+ assert credentials.resolve_token(repo, "ACME/MVP") == "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_full_names=["acme/mvp"])
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")
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_full_names=["Acme/MVP", "acme/mvp"])
52
+ data = json.loads(path.read_text())
53
+ assert data["version"] == credentials.CREDENTIALS_VERSION
54
+ assert data["tokens"]["alk_one"] == ["acme/mvp"]
55
+ assert data["repo_index"]["acme/mvp"] == "alk_one"
56
+ assert "repos" not in data
57
+
58
+
59
+ def test_save_is_idempotent_overwrite(repo: Path) -> None:
60
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
61
+ credentials.save_token("alk_two", repo_full_names=["acme/mvp"])
62
+ assert credentials.resolve_token(repo, "acme/mvp") == "alk_two"
63
+ store = credentials.load_store()
64
+ assert len(store["tokens"]) == 1
65
+ assert store["repo_index"] == {"acme/mvp": "alk_two"}
66
+
67
+
68
+ def test_distinct_repos_get_distinct_entries(tmp_path: Path) -> None:
69
+ a, b = tmp_path / "a", tmp_path / "b"
70
+ a.mkdir()
71
+ b.mkdir()
72
+ credentials.save_token("alk_a", repo_full_names=["acme/a"])
73
+ credentials.save_token("alk_b", repo_full_names=["acme/b"])
74
+ assert credentials.resolve_token(a, "acme/a") == "alk_a"
75
+ assert credentials.resolve_token(b, "acme/b") == "alk_b"
76
+
77
+
78
+ def test_load_store_missing_file_is_empty() -> None:
79
+ assert credentials.load_store() == {
80
+ "version": credentials.CREDENTIALS_VERSION,
81
+ "tokens": {},
82
+ "repo_index": {},
83
+ }
84
+
85
+
86
+ def test_load_store_tolerates_corrupt_file(_isolate_home: Path) -> None:
87
+ path = credentials.credentials_path()
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ path.write_text("{ not json", encoding="utf-8")
90
+ assert credentials.load_store() == {
91
+ "version": credentials.CREDENTIALS_VERSION,
92
+ "tokens": {},
93
+ "repo_index": {},
94
+ }
95
+
96
+
97
+ def test_remove_token(repo: Path) -> None:
98
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
99
+ assert credentials.remove_token(repo_full_name="acme/mvp") is True
100
+ assert credentials.resolve_token(repo, "acme/mvp") is None
101
+ # Removing again is a no-op.
102
+ assert credentials.remove_token() is False
103
+
104
+
105
+ def test_describe_source_repo_entry(repo: Path) -> None:
106
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
107
+ assert credentials.describe_source(repo, "acme/mvp") == (
108
+ "the saved token for acme/mvp"
109
+ )
110
+
111
+
112
+ def test_describe_source_env_var(repo: Path, monkeypatch: pytest.MonkeyPatch) -> None:
113
+ monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
114
+ # No stored entry for this repo -> falls through to the env-var source.
115
+ assert credentials.describe_source(repo) == (
116
+ f"the {credentials.API_TOKEN_ENV} environment variable"
117
+ )
118
+
119
+
120
+ def test_describe_source_repo_entry_wins_over_env(
121
+ repo: Path, monkeypatch: pytest.MonkeyPatch
122
+ ) -> None:
123
+ monkeypatch.setenv(credentials.API_TOKEN_ENV, "alk_env")
124
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
125
+ assert credentials.describe_source(repo, "acme/mvp") == (
126
+ "the saved token for acme/mvp"
127
+ )
128
+
129
+
130
+ def test_describe_source_none(repo: Path) -> None:
131
+ assert credentials.describe_source(repo) is None
132
+
133
+
134
+ def test_load_store_drops_legacy_path_store(repo: Path) -> None:
135
+ path = credentials.credentials_path()
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ path.write_text(
138
+ json.dumps({"version": 1, "repos": {str(repo.resolve()): "alk_old"}}),
139
+ encoding="utf-8",
140
+ )
141
+
142
+ store = credentials.load_store()
143
+ assert "repos" not in store
144
+ assert credentials.resolve_token(repo, "acme/mvp") is None
145
+
146
+
147
+ def test_load_store_builds_repo_index_from_token_only_v2_store() -> None:
148
+ path = credentials.credentials_path()
149
+ path.parent.mkdir(parents=True, exist_ok=True)
150
+ path.write_text(
151
+ json.dumps({"version": 2, "tokens": {"alk_one": ["Acme/MVP"]}}),
152
+ encoding="utf-8",
153
+ )
154
+
155
+ assert credentials.load_store()["repo_index"] == {"acme/mvp": "alk_one"}
156
+ assert credentials.resolve_token(None, "acme/mvp") == "alk_one"
157
+
158
+
159
+ def test_resolve_repo_name_after_reclone(repo: Path, tmp_path: Path) -> None:
160
+ moved = tmp_path / "moved"
161
+ moved.mkdir()
162
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
163
+
164
+ assert credentials.resolve_token(moved, "acme/mvp") == "alk_one"
165
+
166
+
167
+ def test_remove_token_keeps_other_repo_names(repo: Path) -> None:
168
+ credentials.save_token(
169
+ "alk_one", repo_full_names=["acme/mvp", "acme/docs"]
170
+ )
171
+
172
+ assert credentials.remove_token(repo_full_name="acme/mvp") is True
173
+ assert credentials.resolve_token(None, "acme/mvp") is None
174
+ assert credentials.resolve_token(None, "acme/docs") == "alk_one"
175
+
176
+
177
+ def test_resaving_same_token_removes_stale_repo_index_entries(repo: Path) -> None:
178
+ credentials.save_token(
179
+ "alk_one", repo_full_names=["acme/mvp", "acme/docs"]
180
+ )
181
+ credentials.save_token("alk_one", repo_full_names=["acme/mvp"])
182
+
183
+ store = credentials.load_store()
184
+ assert store["tokens"]["alk_one"] == ["acme/mvp"]
185
+ assert store["repo_index"] == {"acme/mvp": "alk_one"}
186
+ 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