baudrier 0.2.2__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.
- baudrier-0.2.2/.gitignore +14 -0
- baudrier-0.2.2/LICENSE +28 -0
- baudrier-0.2.2/PKG-INFO +76 -0
- baudrier-0.2.2/README.md +27 -0
- baudrier-0.2.2/pyproject.toml +36 -0
- baudrier-0.2.2/src/baudrier_core/__init__.py +9 -0
- baudrier-0.2.2/src/baudrier_core/account.py +250 -0
- baudrier-0.2.2/src/baudrier_core/cli.py +514 -0
- baudrier-0.2.2/src/baudrier_core/credentials.py +171 -0
- baudrier-0.2.2/src/baudrier_core/errors.py +10 -0
- baudrier-0.2.2/src/baudrier_core/hashing.py +35 -0
- baudrier-0.2.2/src/baudrier_core/import_cmd.py +687 -0
- baudrier-0.2.2/src/baudrier_core/init_cmd.py +528 -0
- baudrier-0.2.2/src/baudrier_core/mcp_proxy.py +97 -0
- baudrier-0.2.2/src/baudrier_core/models.py +27 -0
- baudrier-0.2.2/src/baudrier_core/project_ref.py +88 -0
- baudrier-0.2.2/src/baudrier_core/promote_cmd.py +105 -0
- baudrier-0.2.2/src/baudrier_core/resolver.py +246 -0
- baudrier-0.2.2/src/baudrier_core/schema/harness.schema.json +715 -0
- baudrier-0.2.2/src/baudrier_core/secrets_scan.py +24 -0
- baudrier-0.2.2/src/baudrier_core/sync_cmd.py +372 -0
- baudrier-0.2.2/src/baudrier_core/token_errors.py +81 -0
- baudrier-0.2.2/src/baudrier_core/validate.py +42 -0
- baudrier-0.2.2/tests/conftest.py +39 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-backend/README.md +9 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-backend/harness.yaml +15 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-backend/instructions/operational.md +6 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-backend/skills/api-testing/SKILL.md +12 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-frontend/README.md +9 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-frontend/harness.yaml +15 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-frontend/instructions/operational.md +6 -0
- baudrier-0.2.2/tests/fixtures/baudrier-harness-frontend/skills/frontend-testing/SKILL.md +12 -0
- baudrier-0.2.2/tests/test_account.py +230 -0
- baudrier-0.2.2/tests/test_credentials.py +155 -0
- baudrier-0.2.2/tests/test_decline.py +261 -0
- baudrier-0.2.2/tests/test_deploy_files.py +85 -0
- baudrier-0.2.2/tests/test_import.py +591 -0
- baudrier-0.2.2/tests/test_import_ready.py +219 -0
- baudrier-0.2.2/tests/test_init.py +771 -0
- baudrier-0.2.2/tests/test_labels.py +79 -0
- baudrier-0.2.2/tests/test_learning_loop_flow.py +37 -0
- baudrier-0.2.2/tests/test_mcp_proxy.py +265 -0
- baudrier-0.2.2/tests/test_onboarding_files.py +70 -0
- baudrier-0.2.2/tests/test_overrides_and_loader.py +84 -0
- baudrier-0.2.2/tests/test_package.py +54 -0
- baudrier-0.2.2/tests/test_project_cmds.py +158 -0
- baudrier-0.2.2/tests/test_project_ref.py +184 -0
- baudrier-0.2.2/tests/test_promote.py +206 -0
- baudrier-0.2.2/tests/test_resolver.py +206 -0
- baudrier-0.2.2/tests/test_secrets_scan.py +17 -0
- baudrier-0.2.2/tests/test_sync.py +348 -0
- baudrier-0.2.2/tests/test_sync_report.py +106 -0
- baudrier-0.2.2/tests/test_token_source.py +253 -0
- baudrier-0.2.2/tests/test_validate_cli.py +90 -0
- baudrier-0.2.2/uv.lock +1604 -0
baudrier-0.2.2/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Baudrier CLI - Proprietary Licence (draft, to be reviewed)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Mondaut. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. Grant. You may install and use this software (the "CLI") solely to access and
|
|
6
|
+
use the Baudrier service.
|
|
7
|
+
|
|
8
|
+
2. Restrictions. You may not redistribute, sublicense, sell, resell or rent the CLI;
|
|
9
|
+
modify it for the purpose of redistribution; or reverse engineer, decompile or
|
|
10
|
+
attempt to extract the source or workings of the Baudrier service.
|
|
11
|
+
|
|
12
|
+
3. Ownership. The CLI and the Baudrier service remain the property of the copyright
|
|
13
|
+
holder. No rights are granted except those stated above.
|
|
14
|
+
|
|
15
|
+
4. No warranty. The CLI is provided "as is", without warranty of any kind, express
|
|
16
|
+
or implied, including merchantability, fitness for a particular purpose and
|
|
17
|
+
non-infringement.
|
|
18
|
+
|
|
19
|
+
5. Limitation of liability. To the maximum extent permitted by law, the copyright
|
|
20
|
+
holder is not liable for any indirect, incidental or consequential damages, or
|
|
21
|
+
for any loss of data or profits, arising from the use of the CLI. Total
|
|
22
|
+
liability is limited to the amount you paid for the Baudrier service in the
|
|
23
|
+
twelve months before the claim (zero for free beta use).
|
|
24
|
+
|
|
25
|
+
6. Termination. This licence ends automatically if you breach it.
|
|
26
|
+
|
|
27
|
+
7. Governing law. This licence is governed by French law. Courts of France have
|
|
28
|
+
jurisdiction, subject to any mandatory consumer protection rules.
|
baudrier-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: baudrier
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Baudrier CLI: connect projects to your AI coding harness and validate harness repositories
|
|
5
|
+
Project-URL: Homepage, https://jack-of-all-trades.dev
|
|
6
|
+
License: Baudrier CLI - Proprietary Licence (draft, to be reviewed)
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Jonathan Mondaut. All rights reserved.
|
|
9
|
+
|
|
10
|
+
1. Grant. You may install and use this software (the "CLI") solely to access and
|
|
11
|
+
use the Baudrier service.
|
|
12
|
+
|
|
13
|
+
2. Restrictions. You may not redistribute, sublicense, sell, resell or rent the CLI;
|
|
14
|
+
modify it for the purpose of redistribution; or reverse engineer, decompile or
|
|
15
|
+
attempt to extract the source or workings of the Baudrier service.
|
|
16
|
+
|
|
17
|
+
3. Ownership. The CLI and the Baudrier service remain the property of the copyright
|
|
18
|
+
holder. No rights are granted except those stated above.
|
|
19
|
+
|
|
20
|
+
4. No warranty. The CLI is provided "as is", without warranty of any kind, express
|
|
21
|
+
or implied, including merchantability, fitness for a particular purpose and
|
|
22
|
+
non-infringement.
|
|
23
|
+
|
|
24
|
+
5. Limitation of liability. To the maximum extent permitted by law, the copyright
|
|
25
|
+
holder is not liable for any indirect, incidental or consequential damages, or
|
|
26
|
+
for any loss of data or profits, arising from the use of the CLI. Total
|
|
27
|
+
liability is limited to the amount you paid for the Baudrier service in the
|
|
28
|
+
twelve months before the claim (zero for free beta use).
|
|
29
|
+
|
|
30
|
+
6. Termination. This licence ends automatically if you breach it.
|
|
31
|
+
|
|
32
|
+
7. Governing law. This licence is governed by French law. Courts of France have
|
|
33
|
+
jurisdiction, subject to any mandatory consumer protection rules.
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Classifier: Environment :: Console
|
|
36
|
+
Classifier: License :: Other/Proprietary License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Requires-Python: >=3.11
|
|
41
|
+
Requires-Dist: fastmcp<5,>=4.0
|
|
42
|
+
Requires-Dist: jsonschema>=4.21
|
|
43
|
+
Requires-Dist: pydantic>=2.7
|
|
44
|
+
Requires-Dist: pyyaml>=6.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
47
|
+
Provides-Extra: verify
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# baudrier
|
|
51
|
+
|
|
52
|
+
The command-line client for [Baudrier](https://jack-of-all-trades.dev): it connects a project folder to your
|
|
53
|
+
Baudrier harness so your AI coding agents (Claude Code, Codex, Gemini CLI, Copilot) get the right instructions
|
|
54
|
+
and skills, and it can validate harness repositories.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv tool install baudrier
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
baudrier login # sign in with your browser
|
|
66
|
+
baudrier init --slug <slug> # connect this project folder
|
|
67
|
+
baudrier validate . # validate a harness repository
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The CLI talks to the hosted service (`https://api.baudrier-ai.com`) by default; pass `--url` only for another server.
|
|
71
|
+
|
|
72
|
+
Run `baudrier --help` for all commands. Documentation: https://jack-of-all-trades.dev
|
|
73
|
+
|
|
74
|
+
## Licence
|
|
75
|
+
|
|
76
|
+
Proprietary, all rights reserved. See `LICENSE`. Use is permitted to access the Baudrier service.
|
baudrier-0.2.2/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# baudrier
|
|
2
|
+
|
|
3
|
+
The command-line client for [Baudrier](https://jack-of-all-trades.dev): it connects a project folder to your
|
|
4
|
+
Baudrier harness so your AI coding agents (Claude Code, Codex, Gemini CLI, Copilot) get the right instructions
|
|
5
|
+
and skills, and it can validate harness repositories.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv tool install baudrier
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
baudrier login # sign in with your browser
|
|
17
|
+
baudrier init --slug <slug> # connect this project folder
|
|
18
|
+
baudrier validate . # validate a harness repository
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The CLI talks to the hosted service (`https://api.baudrier-ai.com`) by default; pass `--url` only for another server.
|
|
22
|
+
|
|
23
|
+
Run `baudrier --help` for all commands. Documentation: https://jack-of-all-trades.dev
|
|
24
|
+
|
|
25
|
+
## Licence
|
|
26
|
+
|
|
27
|
+
Proprietary, all rights reserved. See `LICENSE`. Use is permitted to access the Baudrier service.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "baudrier"
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
description = "Baudrier CLI: connect projects to your AI coding harness and validate harness repositories"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["pydantic>=2.7", "pyyaml>=6.0", "jsonschema>=4.21", "fastmcp>=4.0,<5"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"License :: Other/Proprietary License",
|
|
11
|
+
"Programming Language :: Python :: 3.11",
|
|
12
|
+
"Programming Language :: Python :: 3.12",
|
|
13
|
+
"Programming Language :: Python :: 3.13",
|
|
14
|
+
"Environment :: Console",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://jack-of-all-trades.dev"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=8"]
|
|
22
|
+
verify = [] # kept so older `baudrier[verify]` install lines still resolve; fastmcp is a default dependency
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
baudrier = "baudrier_core.cli:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/baudrier_core"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
markers = ["slow: builds and installs the wheel in a clean venv (packaging guard)"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from baudrier_core.errors import BaudrierError, HarnessResolutionError, HarnessValidationError
|
|
2
|
+
from baudrier_core.hashing import tree_hash
|
|
3
|
+
from baudrier_core.models import ResolvedHarness, ResolvedSkill
|
|
4
|
+
from baudrier_core.resolver import load_harness, resolve_harness_at, validate_harness
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"BaudrierError", "HarnessResolutionError", "HarnessValidationError", "ResolvedHarness",
|
|
8
|
+
"ResolvedSkill", "load_harness", "resolve_harness_at", "tree_hash", "validate_harness",
|
|
9
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""The CLI's REST client for the account routes (stdlib only: no new dependency)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
from baudrier_core import credentials
|
|
14
|
+
from baudrier_core.errors import BaudrierError
|
|
15
|
+
|
|
16
|
+
TIMEOUT_S = 15
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AccountError(BaudrierError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NotLoggedIn(AccountError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_label() -> str:
|
|
28
|
+
return f"{socket.gethostname().split('.')[0]} cli"[:100]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clean_label(text: str | None) -> str:
|
|
32
|
+
cleaned = "".join(c for c in (text or "") if c.isprintable()).strip()[:100].strip()
|
|
33
|
+
return cleaned or default_label()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _scrub(text: str, token: str | None) -> str:
|
|
37
|
+
return text.replace(token, "***") if token else text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
41
|
+
"""Never follow a redirect: urllib would resend the Authorization header to whatever host it names."""
|
|
42
|
+
|
|
43
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: PLR0913 (urllib's signature)
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version() -> str:
|
|
51
|
+
try:
|
|
52
|
+
return importlib.metadata.version("baudrier")
|
|
53
|
+
except importlib.metadata.PackageNotFoundError:
|
|
54
|
+
return "dev"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
USER_AGENT = f"baudrier/{_version()}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _open(req: urllib.request.Request, timeout: float):
|
|
61
|
+
return _OPENER.open(req, timeout=timeout)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _need(data: object, *keys: str) -> dict:
|
|
65
|
+
"""The JSON object with these keys, or an AccountError (never a KeyError) when the server sent something else."""
|
|
66
|
+
if not isinstance(data, dict) or any(k not in data for k in keys):
|
|
67
|
+
raise AccountError(f"unexpected answer from the server (missing {', '.join(keys)})")
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def request(method: str, url: str, *, token: str | None = None, body: object = None,
|
|
72
|
+
headers: dict | None = None, timeout: float | None = None) -> tuple[int, object]:
|
|
73
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
74
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
75
|
+
req.add_header("User-Agent", USER_AGENT) # Cloudflare blocks urllib's default one (error 1010)
|
|
76
|
+
req.add_header("Accept", "application/json")
|
|
77
|
+
if data is not None:
|
|
78
|
+
req.add_header("Content-Type", "application/json")
|
|
79
|
+
if token:
|
|
80
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
81
|
+
for k, v in (headers or {}).items():
|
|
82
|
+
req.add_header(k, v)
|
|
83
|
+
try:
|
|
84
|
+
with _open(req, TIMEOUT_S if timeout is None else timeout) as resp:
|
|
85
|
+
raw = resp.read()
|
|
86
|
+
return resp.status, (json.loads(raw) if raw else None)
|
|
87
|
+
except urllib.error.HTTPError as exc:
|
|
88
|
+
if 300 <= exc.code < 400:
|
|
89
|
+
location = _scrub(str(exc.headers.get("Location", "")), token)
|
|
90
|
+
raise AccountError(f"the server redirected to {location}; use that as --url") from None
|
|
91
|
+
try:
|
|
92
|
+
return exc.code, json.loads(exc.read() or b"null")
|
|
93
|
+
except ValueError:
|
|
94
|
+
return exc.code, None
|
|
95
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
96
|
+
reason = getattr(exc, "reason", exc)
|
|
97
|
+
raise AccountError(_scrub(f"cannot reach {url.split('/api/')[0]}: {reason}", token)) from None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _detail(data: object, status: int) -> str:
|
|
101
|
+
d = data.get("detail") if isinstance(data, dict) else None
|
|
102
|
+
if isinstance(d, list):
|
|
103
|
+
d = "; ".join(str(x.get("msg", x)) if isinstance(x, dict) else str(x) for x in d)
|
|
104
|
+
return str(d or f"HTTP {status}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def call(base: str, method: str, path: str, *, token: str, body: object = None) -> object:
|
|
108
|
+
status, data = request(method, credentials.norm_url(base) + path, token=token, body=body)
|
|
109
|
+
if status == 401:
|
|
110
|
+
raise NotLoggedIn("not logged in, or your login expired: run `baudrier login`")
|
|
111
|
+
if status >= 400:
|
|
112
|
+
raise AccountError(_scrub(_detail(data, status), token))
|
|
113
|
+
return data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _say(text: str) -> None:
|
|
117
|
+
print(text, flush=True) # the code must show at once even when stdout is a pipe or a file (no TTY)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def login(base: str, label: str, *, open_browser: bool = True, out=_say, sleep=time.sleep,
|
|
121
|
+
clock=time.monotonic) -> dict:
|
|
122
|
+
base = credentials.norm_url(base)
|
|
123
|
+
status, d = request("POST", f"{base}/api/cli/device", body={"label": label})
|
|
124
|
+
if status != 200 or not isinstance(d, dict):
|
|
125
|
+
raise AccountError(f"could not start the login: {_detail(d, status)}")
|
|
126
|
+
_need(d, "device_code", "user_code", "verification_uri", "verification_uri_complete")
|
|
127
|
+
out(f"Open {d['verification_uri']} and check that it shows the code {d['user_code']}")
|
|
128
|
+
if open_browser:
|
|
129
|
+
try:
|
|
130
|
+
webbrowser.open(d["verification_uri_complete"])
|
|
131
|
+
except webbrowser.Error:
|
|
132
|
+
pass
|
|
133
|
+
interval = int(d.get("interval", 5))
|
|
134
|
+
deadline = clock() + int(d.get("expires_in", 600))
|
|
135
|
+
while clock() < deadline:
|
|
136
|
+
sleep(interval)
|
|
137
|
+
status, r = request("POST", f"{base}/api/cli/device/token", body={"device_code": d["device_code"]})
|
|
138
|
+
if status == 429:
|
|
139
|
+
interval += 5
|
|
140
|
+
continue
|
|
141
|
+
if status >= 400 or not isinstance(r, dict):
|
|
142
|
+
raise AccountError(f"login failed: {_detail(r, status)}")
|
|
143
|
+
if r.get("status") == "approved":
|
|
144
|
+
_need(r, "token", "login", "expires_at")
|
|
145
|
+
credentials.set_account(base, r["token"], r["login"], r["expires_at"])
|
|
146
|
+
return {"login": r["login"], "expires_at": r["expires_at"]}
|
|
147
|
+
if r.get("status") == "denied":
|
|
148
|
+
raise AccountError("the login was denied in the browser")
|
|
149
|
+
if r.get("status") == "expired":
|
|
150
|
+
break
|
|
151
|
+
raise AccountError("the code expired; run `baudrier login` again")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def logout(base: str, projects: bool = False) -> None:
|
|
155
|
+
acc = credentials.account(base)
|
|
156
|
+
if acc and acc.get("token"):
|
|
157
|
+
try:
|
|
158
|
+
request("DELETE", credentials.norm_url(base) + "/api/account/tokens/current", token=acc["token"])
|
|
159
|
+
except AccountError:
|
|
160
|
+
pass # best effort: forget it locally anyway
|
|
161
|
+
credentials.clear_account(base, projects=projects)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def whoami(base: str) -> dict:
|
|
165
|
+
token = credentials.account_token(base)
|
|
166
|
+
if not token:
|
|
167
|
+
raise NotLoggedIn("not logged in, or your login expired: run `baudrier login`")
|
|
168
|
+
me = _need(call(base, "GET", "/api/auth/me", token=token), "login")
|
|
169
|
+
acc = credentials.account(base) or {}
|
|
170
|
+
return {"login": me["login"], "url": credentials.norm_url(base), "expires_at": acc.get("expires_at", "")}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_REPO_REF = re.compile(
|
|
174
|
+
r"^(?:https?://)?github\.com/(?P<repo>[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?"
|
|
175
|
+
r"(?:/(?P<path>[^@#]+?))?/?(?:@(?P<ref>[^#@]+))?(?:#(?P<name>[^#]+))?$")
|
|
176
|
+
# `owner/repo[/<path>][@<ref>][#<name>]` without the host: GitHub owners have no dots, so `gitlab.com/a/b` stays rejected.
|
|
177
|
+
_SHORT_REF = re.compile(
|
|
178
|
+
r"^(?P<repo>[A-Za-z0-9-]+/[A-Za-z0-9_.-]+?)(?:\.git)?"
|
|
179
|
+
r"(?:/(?P<path>[^@#]+?))?/?(?:@(?P<ref>[^#@]+))?(?:#(?P<name>[^#]+))?$")
|
|
180
|
+
_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_harness_ref(ref: str) -> dict:
|
|
184
|
+
ref = ref.strip()
|
|
185
|
+
m = _REPO_REF.match(ref) or _SHORT_REF.match(ref)
|
|
186
|
+
if m:
|
|
187
|
+
return {"kind": "repo", "repo": m["repo"], "path": m["path"], "ref": m["ref"], "name": m["name"]}
|
|
188
|
+
if "/" not in ref and _NAME.match(ref):
|
|
189
|
+
return {"kind": "name", "name": ref}
|
|
190
|
+
raise AccountError(f"{ref!r} is neither a harness name nor [github.com/]<owner>/<repo>[/<path>][@<ref>][#<name>]")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def find_project(base: str, token: str, slug: str) -> dict:
|
|
194
|
+
for p in call(base, "GET", "/api/projects", token=token):
|
|
195
|
+
if p["slug"] == slug:
|
|
196
|
+
return p
|
|
197
|
+
raise AccountError(f"no project {slug!r} in your account; create it with `baudrier project create`")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def create_project(base: str, token: str, name: str, slug: str | None) -> dict:
|
|
201
|
+
return call(base, "POST", "/api/projects", token=token, body={"name": name, "slug": slug})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _pick(base: str, token: str, ref: dict) -> dict:
|
|
205
|
+
if ref["kind"] == "name":
|
|
206
|
+
matches = [h for h in call(base, "GET", "/api/harnesses", token=token) if h["name"] == ref["name"]]
|
|
207
|
+
if not matches:
|
|
208
|
+
raise AccountError(f"no harness named {ref['name']!r} in your Library; add its repo with "
|
|
209
|
+
"`baudrier add harness github.com/<owner>/<repo>`")
|
|
210
|
+
if len(matches) > 1:
|
|
211
|
+
where = ", ".join(f"{h['repo']}/{h['path']}".rstrip("/") for h in matches)
|
|
212
|
+
raise AccountError(f"several harnesses are named {ref['name']!r} ({where}); use "
|
|
213
|
+
f"github.com/<owner>/<repo>#{ref['name']} to choose")
|
|
214
|
+
return matches[0]
|
|
215
|
+
body = {"repo": ref["repo"], "ref": ref["ref"] or "main"}
|
|
216
|
+
if ref["path"]:
|
|
217
|
+
body["path"] = ref["path"]
|
|
218
|
+
found = call(base, "POST", "/api/sources", token=token, body=body)
|
|
219
|
+
if ref["name"]:
|
|
220
|
+
found = [h for h in found if h["name"] == ref["name"]]
|
|
221
|
+
if not found:
|
|
222
|
+
raise AccountError(f"{ref['repo']} has no harness named {ref['name']!r}")
|
|
223
|
+
if len(found) > 1:
|
|
224
|
+
names = ", ".join(h["name"] for h in found)
|
|
225
|
+
raise AccountError(f"{ref['repo']} has several harnesses ({names}); pick one with #<name>")
|
|
226
|
+
return found[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def add_harnesses(base: str, token: str, slug: str, refs: list[str]) -> list[str]:
|
|
230
|
+
parsed = [parse_harness_ref(r) for r in refs] # all refs are checked before anything is attached
|
|
231
|
+
project = find_project(base, token, slug)
|
|
232
|
+
names = []
|
|
233
|
+
for ref in parsed:
|
|
234
|
+
h = _pick(base, token, ref)
|
|
235
|
+
call(base, "POST", f"/api/projects/{project['id']}/harnesses", token=token, body={"harness_id": h["id"]})
|
|
236
|
+
names.append(h["name"])
|
|
237
|
+
return names
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def ensure_project_token(base: str, slug: str, label: str) -> bool:
|
|
241
|
+
if credentials.project_token(base, slug):
|
|
242
|
+
return False
|
|
243
|
+
token = credentials.account_token(base)
|
|
244
|
+
if not token:
|
|
245
|
+
raise NotLoggedIn("no token for this project: run `baudrier login` first (or set BAUDRIER_TOKEN)")
|
|
246
|
+
project = find_project(base, token, slug)
|
|
247
|
+
created = _need(call(base, "POST", f"/api/projects/{project['id']}/tokens", token=token, body={"label": label}),
|
|
248
|
+
"token")
|
|
249
|
+
credentials.set_project_token(base, slug, created["token"])
|
|
250
|
+
return True
|