agent-trace-cli 0.1.0__py3-none-any.whl
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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/remote.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote configuration management — git remote-like model.
|
|
3
|
+
|
|
4
|
+
Each project can have multiple named remotes. The remote URL has the shape
|
|
5
|
+
``<scheme>://<host>[:port]/<org_slug>/<project_slug>`` — mirroring
|
|
6
|
+
``github.com/<org>/<repo>``. The ``project_slug`` parsed out of the URL is
|
|
7
|
+
the **wire** ``project_id`` used by the sync routes; the local on-disk
|
|
8
|
+
data directory still uses the anchor-derived local id, so the
|
|
9
|
+
standalone-no-service flow is unaffected.
|
|
10
|
+
|
|
11
|
+
Tokens are stored separately:
|
|
12
|
+
- ``global:<project_id>::<name>`` → ``~/.agent-trace/config.json`` under
|
|
13
|
+
``tokens.<project_id>::<name>``. Scoping by ``project_id`` keeps two
|
|
14
|
+
projects that both call a remote ``origin`` from clobbering each
|
|
15
|
+
other's token.
|
|
16
|
+
- ``env:<VAR>`` → ``os.environ[VAR]`` at runtime (never persisted)
|
|
17
|
+
- ``keychain:<name>``→ OS keychain (scaffold only — not yet implemented)
|
|
18
|
+
|
|
19
|
+
Legacy bare-name refs (``global:<name>``) written by older versions still
|
|
20
|
+
resolve — ``resolve_token`` treats whatever follows ``global:`` as a flat
|
|
21
|
+
key into ``tokens``.
|
|
22
|
+
|
|
23
|
+
No external dependencies — stdlib only.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import urllib.parse
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from .storage import ensure_project_dir, get_project_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# -------------------------------------------------------------------
|
|
39
|
+
# URL parsing
|
|
40
|
+
# -------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Mirrors the server-side CHECK on orgs.slug and projects.project_id.
|
|
43
|
+
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RemoteUrlError(ValueError):
|
|
47
|
+
"""Raised when a remote URL doesn't match the slug-grammar."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_remote_url(url: str) -> tuple[str, str, str]:
|
|
51
|
+
"""Parse a remote URL into (base_url, org_slug, project_slug).
|
|
52
|
+
|
|
53
|
+
Accepts: ``<scheme>://<host>[:port]/<org_slug>/<project_slug>`` with an
|
|
54
|
+
optional trailing slash. Rejects bare-host URLs and URLs whose path
|
|
55
|
+
component does not match the slug grammar.
|
|
56
|
+
|
|
57
|
+
Raises ``RemoteUrlError`` with a hint pointing at the expected shape.
|
|
58
|
+
"""
|
|
59
|
+
parsed = urllib.parse.urlsplit(url.strip())
|
|
60
|
+
if parsed.scheme not in ("http", "https"):
|
|
61
|
+
raise RemoteUrlError(
|
|
62
|
+
f"Unsupported scheme {parsed.scheme!r}. Expected http or https."
|
|
63
|
+
)
|
|
64
|
+
if not parsed.netloc:
|
|
65
|
+
raise RemoteUrlError(f"URL is missing host: {url!r}")
|
|
66
|
+
|
|
67
|
+
path = parsed.path.strip("/")
|
|
68
|
+
parts = [p for p in path.split("/") if p]
|
|
69
|
+
if len(parts) != 2:
|
|
70
|
+
raise RemoteUrlError(
|
|
71
|
+
"Remote URL must include the project path. "
|
|
72
|
+
f"Expected ``<scheme>://<host>/<org_slug>/<project_slug>``; got {url!r}. "
|
|
73
|
+
"Run `agent-trace project create <url>` if you need to register the slug first."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
org_slug, project_slug = parts
|
|
77
|
+
if not _SLUG_RE.match(org_slug):
|
|
78
|
+
raise RemoteUrlError(
|
|
79
|
+
f"Org slug {org_slug!r} must match {_SLUG_RE.pattern}."
|
|
80
|
+
)
|
|
81
|
+
if not _SLUG_RE.match(project_slug):
|
|
82
|
+
raise RemoteUrlError(
|
|
83
|
+
f"Project slug {project_slug!r} must match {_SLUG_RE.pattern}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
base = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
87
|
+
return base, org_slug, project_slug
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_slug(value: str) -> bool:
|
|
91
|
+
return bool(_SLUG_RE.match(value or ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -------------------------------------------------------------------
|
|
95
|
+
# File helpers
|
|
96
|
+
# -------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _remotes_path(project_id: str) -> Path:
|
|
99
|
+
return get_project_dir(project_id) / "remotes.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_remotes(project_id: str) -> dict[str, Any]:
|
|
103
|
+
p = _remotes_path(project_id)
|
|
104
|
+
if not p.is_file():
|
|
105
|
+
return {}
|
|
106
|
+
try:
|
|
107
|
+
data = json.loads(p.read_text())
|
|
108
|
+
return data if isinstance(data, dict) else {}
|
|
109
|
+
except (json.JSONDecodeError, OSError):
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _save_remotes(project_id: str, remotes: dict[str, Any]) -> None:
|
|
114
|
+
ensure_project_dir(project_id)
|
|
115
|
+
p = _remotes_path(project_id)
|
|
116
|
+
p.write_text(json.dumps(remotes, indent=2) + "\n")
|
|
117
|
+
try:
|
|
118
|
+
p.chmod(0o600)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -------------------------------------------------------------------
|
|
124
|
+
# Token resolution
|
|
125
|
+
# -------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def _token_key(project_id: str, name: str) -> str:
|
|
128
|
+
"""Per-project key for global token storage.
|
|
129
|
+
|
|
130
|
+
``::`` separates the project_id from the remote name. project_ids are
|
|
131
|
+
either opaque ``at-<hex>`` anchors or path-derived slugs — neither
|
|
132
|
+
contains ``::`` — so the join is unambiguous.
|
|
133
|
+
"""
|
|
134
|
+
return f"{project_id}::{name}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_token(token_ref: str) -> str | None:
|
|
138
|
+
"""Resolve a token reference to an actual token value.
|
|
139
|
+
|
|
140
|
+
Supported schemes:
|
|
141
|
+
``global:<key>`` — reads ``tokens.<key>`` from global config.
|
|
142
|
+
Modern refs use ``<project_id>::<name>``;
|
|
143
|
+
legacy bare-name keys still work.
|
|
144
|
+
``env:<VAR>`` — reads environment variable
|
|
145
|
+
``keychain:<name>``— (stub) returns None
|
|
146
|
+
raw string — returned as-is (direct token)
|
|
147
|
+
"""
|
|
148
|
+
if token_ref.startswith("global:"):
|
|
149
|
+
key = token_ref[7:]
|
|
150
|
+
from .config import get_global_config
|
|
151
|
+
tokens = get_global_config().get("tokens", {})
|
|
152
|
+
return tokens.get(key)
|
|
153
|
+
|
|
154
|
+
if token_ref.startswith("env:"):
|
|
155
|
+
var = token_ref[4:]
|
|
156
|
+
return os.environ.get(var)
|
|
157
|
+
|
|
158
|
+
if token_ref.startswith("keychain:"):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
return token_ref
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _store_global_token(project_id: str, name: str, token: str) -> str:
|
|
165
|
+
"""Store a token under a per-project key. Returns the storage key so
|
|
166
|
+
the caller can write the matching ``token_ref``.
|
|
167
|
+
"""
|
|
168
|
+
from .config import get_global_config, save_global_config
|
|
169
|
+
key = _token_key(project_id, name)
|
|
170
|
+
cfg = get_global_config()
|
|
171
|
+
tokens = cfg.setdefault("tokens", {})
|
|
172
|
+
tokens[key] = token
|
|
173
|
+
save_global_config(cfg)
|
|
174
|
+
return key
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _delete_global_token(project_id: str, name: str) -> None:
|
|
178
|
+
"""Remove the per-project token entry from global config, if present."""
|
|
179
|
+
from .config import get_global_config, save_global_config
|
|
180
|
+
key = _token_key(project_id, name)
|
|
181
|
+
cfg = get_global_config()
|
|
182
|
+
tokens = cfg.get("tokens") or {}
|
|
183
|
+
if key in tokens:
|
|
184
|
+
del tokens[key]
|
|
185
|
+
cfg["tokens"] = tokens
|
|
186
|
+
save_global_config(cfg)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _mask_token(token: str | None) -> str:
|
|
190
|
+
"""Mask a token for safe display."""
|
|
191
|
+
if not token:
|
|
192
|
+
return "(unresolved)"
|
|
193
|
+
if len(token) <= 8:
|
|
194
|
+
return "****"
|
|
195
|
+
return f"{'*' * 8}...{token[-4:]}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# -------------------------------------------------------------------
|
|
199
|
+
# CRUD operations
|
|
200
|
+
# -------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def add_remote(
|
|
203
|
+
project_id: str,
|
|
204
|
+
name: str,
|
|
205
|
+
url: str,
|
|
206
|
+
*,
|
|
207
|
+
token: str | None = None,
|
|
208
|
+
token_env: str | None = None,
|
|
209
|
+
token_keychain: str | None = None,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
"""Add a new named remote. Returns the remote config dict.
|
|
212
|
+
|
|
213
|
+
The URL must include the ``<org>/<project>`` path; ``parse_remote_url``
|
|
214
|
+
raises ``RemoteUrlError`` otherwise.
|
|
215
|
+
"""
|
|
216
|
+
remotes = _load_remotes(project_id)
|
|
217
|
+
if name in remotes:
|
|
218
|
+
raise ValueError(f"Remote '{name}' already exists. Use set-url to change its URL.")
|
|
219
|
+
|
|
220
|
+
base_url, org_slug, project_slug = parse_remote_url(url)
|
|
221
|
+
|
|
222
|
+
auth: dict[str, str] | None = None
|
|
223
|
+
if token:
|
|
224
|
+
key = _store_global_token(project_id, name, token)
|
|
225
|
+
auth = {"type": "bearer", "token_ref": f"global:{key}"}
|
|
226
|
+
elif token_env:
|
|
227
|
+
auth = {"type": "bearer", "token_ref": f"env:{token_env}"}
|
|
228
|
+
elif token_keychain:
|
|
229
|
+
auth = {"type": "bearer", "token_ref": f"keychain:{token_keychain}"}
|
|
230
|
+
|
|
231
|
+
entry: dict[str, Any] = {
|
|
232
|
+
"url": url.rstrip("/"),
|
|
233
|
+
"base_url": base_url,
|
|
234
|
+
"org_slug": org_slug,
|
|
235
|
+
"project_slug": project_slug,
|
|
236
|
+
}
|
|
237
|
+
if auth:
|
|
238
|
+
entry["auth"] = auth
|
|
239
|
+
|
|
240
|
+
remotes[name] = entry
|
|
241
|
+
_save_remotes(project_id, remotes)
|
|
242
|
+
return entry
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def get_remote(project_id: str, name: str) -> dict[str, Any] | None:
|
|
246
|
+
"""Return config for a single remote, or None."""
|
|
247
|
+
return _load_remotes(project_id).get(name)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def list_remotes(project_id: str) -> list[dict[str, Any]]:
|
|
251
|
+
"""Return list of ``{name, url, token_ref}`` dicts."""
|
|
252
|
+
remotes = _load_remotes(project_id)
|
|
253
|
+
result = []
|
|
254
|
+
for rname, rconf in remotes.items():
|
|
255
|
+
auth = rconf.get("auth") or {}
|
|
256
|
+
result.append({
|
|
257
|
+
"name": rname,
|
|
258
|
+
"url": rconf.get("url", ""),
|
|
259
|
+
"token_ref": auth.get("token_ref", ""),
|
|
260
|
+
})
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def remove_remote(project_id: str, name: str) -> bool:
|
|
265
|
+
"""Remove a named remote. Returns True if it existed.
|
|
266
|
+
|
|
267
|
+
Also drops the per-project token entry from the global config so a
|
|
268
|
+
deleted remote doesn't leave its secret behind.
|
|
269
|
+
"""
|
|
270
|
+
remotes = _load_remotes(project_id)
|
|
271
|
+
if name not in remotes:
|
|
272
|
+
return False
|
|
273
|
+
del remotes[name]
|
|
274
|
+
_save_remotes(project_id, remotes)
|
|
275
|
+
_delete_global_token(project_id, name)
|
|
276
|
+
return True
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def set_remote_url(project_id: str, name: str, url: str) -> None:
|
|
280
|
+
"""Change the URL for an existing remote. Re-parses to refresh the
|
|
281
|
+
derived slug fields.
|
|
282
|
+
"""
|
|
283
|
+
remotes = _load_remotes(project_id)
|
|
284
|
+
if name not in remotes:
|
|
285
|
+
raise ValueError(f"Remote '{name}' does not exist.")
|
|
286
|
+
base_url, org_slug, project_slug = parse_remote_url(url)
|
|
287
|
+
remotes[name].update({
|
|
288
|
+
"url": url.rstrip("/"),
|
|
289
|
+
"base_url": base_url,
|
|
290
|
+
"org_slug": org_slug,
|
|
291
|
+
"project_slug": project_slug,
|
|
292
|
+
})
|
|
293
|
+
_save_remotes(project_id, remotes)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def set_remote_token(
|
|
297
|
+
project_id: str,
|
|
298
|
+
name: str,
|
|
299
|
+
*,
|
|
300
|
+
token: str | None = None,
|
|
301
|
+
token_env: str | None = None,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Update the auth token for an existing remote."""
|
|
304
|
+
remotes = _load_remotes(project_id)
|
|
305
|
+
if name not in remotes:
|
|
306
|
+
raise ValueError(f"Remote '{name}' does not exist.")
|
|
307
|
+
|
|
308
|
+
if token:
|
|
309
|
+
key = _store_global_token(project_id, name, token)
|
|
310
|
+
remotes[name]["auth"] = {"type": "bearer", "token_ref": f"global:{key}"}
|
|
311
|
+
elif token_env:
|
|
312
|
+
remotes[name]["auth"] = {"type": "bearer", "token_ref": f"env:{token_env}"}
|
|
313
|
+
else:
|
|
314
|
+
raise ValueError("Provide --token or --token-env.")
|
|
315
|
+
|
|
316
|
+
_save_remotes(project_id, remotes)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def rename_remote(project_id: str, old_name: str, new_name: str) -> None:
|
|
320
|
+
"""Rename a remote.
|
|
321
|
+
|
|
322
|
+
If the remote's token lives in the global config under the per-project
|
|
323
|
+
scoped key, migrate it to match the new name and rewrite ``token_ref``.
|
|
324
|
+
"""
|
|
325
|
+
remotes = _load_remotes(project_id)
|
|
326
|
+
if old_name not in remotes:
|
|
327
|
+
raise ValueError(f"Remote '{old_name}' does not exist.")
|
|
328
|
+
if new_name in remotes:
|
|
329
|
+
raise ValueError(f"Remote '{new_name}' already exists.")
|
|
330
|
+
|
|
331
|
+
entry = remotes.pop(old_name)
|
|
332
|
+
auth = entry.get("auth") or {}
|
|
333
|
+
old_ref = auth.get("token_ref", "")
|
|
334
|
+
old_key = _token_key(project_id, old_name)
|
|
335
|
+
if old_ref == f"global:{old_key}":
|
|
336
|
+
from .config import get_global_config, save_global_config
|
|
337
|
+
cfg = get_global_config()
|
|
338
|
+
tokens = cfg.setdefault("tokens", {})
|
|
339
|
+
if old_key in tokens:
|
|
340
|
+
new_key = _token_key(project_id, new_name)
|
|
341
|
+
tokens[new_key] = tokens.pop(old_key)
|
|
342
|
+
save_global_config(cfg)
|
|
343
|
+
entry["auth"] = {**auth, "token_ref": f"global:{new_key}"}
|
|
344
|
+
|
|
345
|
+
remotes[new_name] = entry
|
|
346
|
+
_save_remotes(project_id, remotes)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def get_default_remote(project_id: str) -> str | None:
|
|
350
|
+
"""Return the default remote name.
|
|
351
|
+
|
|
352
|
+
Rules: if only one remote, it's the default.
|
|
353
|
+
If multiple, look for ``default`` key in project config.
|
|
354
|
+
"""
|
|
355
|
+
remotes = _load_remotes(project_id)
|
|
356
|
+
if not remotes:
|
|
357
|
+
return None
|
|
358
|
+
if len(remotes) == 1:
|
|
359
|
+
return next(iter(remotes))
|
|
360
|
+
|
|
361
|
+
from .config import get_project_config
|
|
362
|
+
from .storage import resolve_project_id
|
|
363
|
+
cfg = get_project_config() or {}
|
|
364
|
+
default = cfg.get("remote", {}).get("default")
|
|
365
|
+
if default and default in remotes:
|
|
366
|
+
return default
|
|
367
|
+
if "origin" in remotes:
|
|
368
|
+
return "origin"
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def set_default_remote(project_id: str, name: str) -> None:
|
|
373
|
+
"""Set the default remote in project config."""
|
|
374
|
+
remotes = _load_remotes(project_id)
|
|
375
|
+
if name not in remotes:
|
|
376
|
+
raise ValueError(f"Remote '{name}' does not exist.")
|
|
377
|
+
from .config import get_project_config, save_project_config
|
|
378
|
+
cfg = get_project_config() or {}
|
|
379
|
+
cfg.setdefault("remote", {})["default"] = name
|
|
380
|
+
save_project_config(cfg)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def resolve_remote(project_id: str, name: str | None = None) -> tuple[str, dict[str, Any]]:
|
|
384
|
+
"""Resolve a remote by name (or default). Returns ``(name, config)``."""
|
|
385
|
+
if name is None:
|
|
386
|
+
name = get_default_remote(project_id)
|
|
387
|
+
if name is None:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
"No remote configured. Run 'agent-trace remote add <name> <url>' first."
|
|
390
|
+
)
|
|
391
|
+
conf = get_remote(project_id, name)
|
|
392
|
+
if conf is None:
|
|
393
|
+
raise ValueError(f"Remote '{name}' not found.")
|
|
394
|
+
return name, conf
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def get_remote_url(remote_conf: dict[str, Any]) -> str:
|
|
398
|
+
"""Full user-facing URL (including ``/<org>/<project>``)."""
|
|
399
|
+
return remote_conf.get("url", "")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def get_remote_base_url(remote_conf: dict[str, Any]) -> str:
|
|
403
|
+
"""Service base URL (``<scheme>://<host>``) — used for API calls.
|
|
404
|
+
|
|
405
|
+
Falls back to re-parsing ``url`` for older remotes written before the
|
|
406
|
+
derived fields existed.
|
|
407
|
+
"""
|
|
408
|
+
base = remote_conf.get("base_url")
|
|
409
|
+
if base:
|
|
410
|
+
return base
|
|
411
|
+
url = remote_conf.get("url", "")
|
|
412
|
+
if not url:
|
|
413
|
+
return ""
|
|
414
|
+
try:
|
|
415
|
+
base, _, _ = parse_remote_url(url)
|
|
416
|
+
return base
|
|
417
|
+
except RemoteUrlError:
|
|
418
|
+
return url
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def get_remote_project_slug(remote_conf: dict[str, Any]) -> str | None:
|
|
422
|
+
"""Wire ``project_id`` for sync calls. ``None`` only for malformed remotes."""
|
|
423
|
+
slug = remote_conf.get("project_slug")
|
|
424
|
+
if slug:
|
|
425
|
+
return slug
|
|
426
|
+
url = remote_conf.get("url", "")
|
|
427
|
+
if not url:
|
|
428
|
+
return None
|
|
429
|
+
try:
|
|
430
|
+
_, _, slug = parse_remote_url(url)
|
|
431
|
+
return slug
|
|
432
|
+
except RemoteUrlError:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def get_remote_org_slug(remote_conf: dict[str, Any]) -> str | None:
|
|
437
|
+
slug = remote_conf.get("org_slug")
|
|
438
|
+
if slug:
|
|
439
|
+
return slug
|
|
440
|
+
url = remote_conf.get("url", "")
|
|
441
|
+
if not url:
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
_, slug, _ = parse_remote_url(url)
|
|
445
|
+
return slug
|
|
446
|
+
except RemoteUrlError:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def get_remote_token(remote_conf: dict[str, Any]) -> str | None:
|
|
451
|
+
"""Resolve the auth token for a remote config dict."""
|
|
452
|
+
auth = remote_conf.get("auth")
|
|
453
|
+
if not auth:
|
|
454
|
+
return None
|
|
455
|
+
ref = auth.get("token_ref", "")
|
|
456
|
+
return resolve_token(ref)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def show_remote(project_id: str, name: str) -> dict[str, Any] | None:
|
|
460
|
+
"""Return a display-safe view of a remote (token masked)."""
|
|
461
|
+
conf = get_remote(project_id, name)
|
|
462
|
+
if conf is None:
|
|
463
|
+
return None
|
|
464
|
+
auth = conf.get("auth") or {}
|
|
465
|
+
token_ref = auth.get("token_ref", "")
|
|
466
|
+
resolved = resolve_token(token_ref) if token_ref else None
|
|
467
|
+
return {
|
|
468
|
+
"name": name,
|
|
469
|
+
"url": conf.get("url", ""),
|
|
470
|
+
"base_url": get_remote_base_url(conf),
|
|
471
|
+
"org_slug": get_remote_org_slug(conf) or "",
|
|
472
|
+
"project_slug": get_remote_project_slug(conf) or "",
|
|
473
|
+
"auth_type": auth.get("type", "none"),
|
|
474
|
+
"token_ref": token_ref,
|
|
475
|
+
"token_masked": _mask_token(resolved),
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# -------------------------------------------------------------------
|
|
480
|
+
# Network: token introspection (whoami) + scope guards
|
|
481
|
+
# -------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
class TokenScopeError(Exception):
|
|
484
|
+
"""A token's resolved scope doesn't match the URL it's being used against.
|
|
485
|
+
|
|
486
|
+
Carries the structured ``code`` so CLI handlers (project create, remote
|
|
487
|
+
add, push/pull, doctor) can render a uniform message and the user can
|
|
488
|
+
grep for a stable identifier.
|
|
489
|
+
|
|
490
|
+
Codes:
|
|
491
|
+
- ``org_slug_mismatch`` — token org differs from URL ``<org_slug>``.
|
|
492
|
+
- ``project_scope_mismatch`` — project-scoped token used with a URL
|
|
493
|
+
whose ``<project_slug>`` is a different project.
|
|
494
|
+
- ``whoami_unsupported`` — the service is too old to expose
|
|
495
|
+
``GET /api/v1/auth/whoami``; the caller decides whether to soft-fail.
|
|
496
|
+
- ``whoami_unauthorized`` — token rejected (401).
|
|
497
|
+
- ``whoami_failed`` — any other reason whoami didn't answer.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
def __init__(self, code: str, message: str) -> None:
|
|
501
|
+
self.code = code
|
|
502
|
+
super().__init__(message)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class WhoamiUnsupportedError(TokenScopeError):
|
|
506
|
+
"""Service did not implement ``GET /api/v1/auth/whoami`` (404)."""
|
|
507
|
+
|
|
508
|
+
def __init__(self) -> None:
|
|
509
|
+
super().__init__(
|
|
510
|
+
"whoami_unsupported",
|
|
511
|
+
"Remote service does not implement GET /api/v1/auth/whoami "
|
|
512
|
+
"(upgrade the service to enable strict org/project scope checks).",
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def whoami(base_url: str, token: str, *, timeout: int = 15) -> dict[str, Any]:
|
|
517
|
+
"""Fetch the resolved scope of ``token`` from ``base_url``.
|
|
518
|
+
|
|
519
|
+
Returns a dict with ``org_id``, ``org_slug``, ``project_id_scope``,
|
|
520
|
+
``scopes``. Raises ``WhoamiUnsupportedError`` for 404 (older service)
|
|
521
|
+
and ``TokenScopeError`` for any other failure (401, 5xx, network).
|
|
522
|
+
"""
|
|
523
|
+
import json as _json
|
|
524
|
+
import urllib.error as _ue
|
|
525
|
+
import urllib.request as _ur
|
|
526
|
+
|
|
527
|
+
req = _ur.Request(
|
|
528
|
+
f"{base_url.rstrip('/')}/api/v1/auth/whoami",
|
|
529
|
+
method="GET",
|
|
530
|
+
)
|
|
531
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
532
|
+
try:
|
|
533
|
+
with _ur.urlopen(req, timeout=timeout) as resp:
|
|
534
|
+
return _json.loads(resp.read().decode())
|
|
535
|
+
except _ue.HTTPError as e:
|
|
536
|
+
if e.code == 404:
|
|
537
|
+
raise WhoamiUnsupportedError() from None
|
|
538
|
+
if e.code == 401:
|
|
539
|
+
raise TokenScopeError(
|
|
540
|
+
"whoami_unauthorized",
|
|
541
|
+
"Token rejected by remote service (401). The token may be "
|
|
542
|
+
"invalid, revoked, or not minted by this service.",
|
|
543
|
+
) from None
|
|
544
|
+
raise TokenScopeError(
|
|
545
|
+
"whoami_failed",
|
|
546
|
+
f"GET /api/v1/auth/whoami returned HTTP {e.code}.",
|
|
547
|
+
) from None
|
|
548
|
+
except (_ue.URLError, OSError, TimeoutError) as e:
|
|
549
|
+
raise TokenScopeError(
|
|
550
|
+
"whoami_failed",
|
|
551
|
+
f"Could not reach {base_url}/api/v1/auth/whoami: {e}",
|
|
552
|
+
) from None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def assert_token_matches_url(
|
|
556
|
+
base_url: str,
|
|
557
|
+
token: str,
|
|
558
|
+
*,
|
|
559
|
+
expected_org_slug: str,
|
|
560
|
+
expected_project_slug: str | None = None,
|
|
561
|
+
allow_unsupported: bool = True,
|
|
562
|
+
) -> dict[str, Any]:
|
|
563
|
+
"""Verify the token's scope matches the URL the user typed.
|
|
564
|
+
|
|
565
|
+
- ``expected_org_slug`` MUST equal the slug of the token's org.
|
|
566
|
+
- When the token is project-scoped (``project_id_scope`` set), and
|
|
567
|
+
``expected_project_slug`` is provided, they must be equal.
|
|
568
|
+
|
|
569
|
+
Returns the whoami payload on success. Raises ``TokenScopeError`` on
|
|
570
|
+
mismatch. If the remote service is too old to support ``whoami`` and
|
|
571
|
+
``allow_unsupported`` is True, returns the unsupported error in the
|
|
572
|
+
payload (caller decides to warn vs. fail).
|
|
573
|
+
"""
|
|
574
|
+
try:
|
|
575
|
+
info = whoami(base_url, token)
|
|
576
|
+
except WhoamiUnsupportedError:
|
|
577
|
+
if allow_unsupported:
|
|
578
|
+
return {"_unsupported": True}
|
|
579
|
+
raise
|
|
580
|
+
|
|
581
|
+
actual_org = info.get("org_slug")
|
|
582
|
+
if actual_org and actual_org != expected_org_slug:
|
|
583
|
+
raise TokenScopeError(
|
|
584
|
+
"org_slug_mismatch",
|
|
585
|
+
f"Token belongs to org {actual_org!r} but the URL targets "
|
|
586
|
+
f"org {expected_org_slug!r}. Use a token for {expected_org_slug!r} "
|
|
587
|
+
f"or change the URL's org segment to {actual_org!r}.",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
project_scope = info.get("project_id_scope")
|
|
591
|
+
if (
|
|
592
|
+
project_scope
|
|
593
|
+
and expected_project_slug
|
|
594
|
+
and project_scope != expected_project_slug
|
|
595
|
+
):
|
|
596
|
+
raise TokenScopeError(
|
|
597
|
+
"project_scope_mismatch",
|
|
598
|
+
f"Token is scoped to project {project_scope!r} but the URL targets "
|
|
599
|
+
f"project {expected_project_slug!r}. Use an org-scoped token, a "
|
|
600
|
+
f"token scoped to {expected_project_slug!r}, or change the URL.",
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return info
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# -------------------------------------------------------------------
|
|
607
|
+
# Network: project registration
|
|
608
|
+
# -------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
class ProjectRegistrationError(Exception):
|
|
611
|
+
"""``register_project_via_remote`` raised; carries the HTTP status."""
|
|
612
|
+
|
|
613
|
+
def __init__(self, status: int, message: str, code: str | None = None) -> None:
|
|
614
|
+
self.status = status
|
|
615
|
+
self.code = code
|
|
616
|
+
super().__init__(message)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def register_project_via_remote(
|
|
620
|
+
base_url: str,
|
|
621
|
+
project_slug: str,
|
|
622
|
+
*,
|
|
623
|
+
org_slug: str | None = None,
|
|
624
|
+
token: str | None = None,
|
|
625
|
+
admin_secret: str | None = None,
|
|
626
|
+
name: str | None = None,
|
|
627
|
+
description: str | None = None,
|
|
628
|
+
) -> dict[str, Any]:
|
|
629
|
+
"""POST /api/v1/projects on the service. Returns the project record on
|
|
630
|
+
success.
|
|
631
|
+
|
|
632
|
+
Auth: pass ``token`` for an org-scoped Bearer (must carry
|
|
633
|
+
``projects:write``), or ``admin_secret`` for the X-Admin-Secret path.
|
|
634
|
+
|
|
635
|
+
``org_slug`` is sent in the body so the server cross-checks it against the
|
|
636
|
+
caller's org. The CLI always derives this from the URL's first path
|
|
637
|
+
segment so a URL like ``https://svc/foo-org/myproj`` cannot end up writing
|
|
638
|
+
rows under a *different* org just because the token happened to be valid.
|
|
639
|
+
"""
|
|
640
|
+
import json as _json
|
|
641
|
+
import urllib.error as _ue
|
|
642
|
+
import urllib.request as _ur
|
|
643
|
+
|
|
644
|
+
body: dict[str, Any] = {"project_id": project_slug}
|
|
645
|
+
if org_slug is not None:
|
|
646
|
+
body["org_slug"] = org_slug
|
|
647
|
+
if name is not None:
|
|
648
|
+
body["name"] = name
|
|
649
|
+
if description is not None:
|
|
650
|
+
body["description"] = description
|
|
651
|
+
|
|
652
|
+
req = _ur.Request(
|
|
653
|
+
f"{base_url.rstrip('/')}/api/v1/projects",
|
|
654
|
+
data=_json.dumps(body).encode("utf-8"),
|
|
655
|
+
method="POST",
|
|
656
|
+
)
|
|
657
|
+
req.add_header("Content-Type", "application/json")
|
|
658
|
+
if admin_secret:
|
|
659
|
+
req.add_header("X-Admin-Secret", admin_secret)
|
|
660
|
+
if token:
|
|
661
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
662
|
+
|
|
663
|
+
try:
|
|
664
|
+
with _ur.urlopen(req, timeout=30) as resp:
|
|
665
|
+
return _json.loads(resp.read().decode())
|
|
666
|
+
except _ue.HTTPError as e:
|
|
667
|
+
raw = e.read().decode() if e.fp else ""
|
|
668
|
+
try:
|
|
669
|
+
payload = _json.loads(raw) if raw else {}
|
|
670
|
+
except _json.JSONDecodeError:
|
|
671
|
+
payload = {"raw": raw}
|
|
672
|
+
msg = payload.get("error") or f"HTTP {e.code}"
|
|
673
|
+
raise ProjectRegistrationError(e.code, msg, payload.get("code")) from None
|