blockmachine 0.1.0__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.
- blockmachine-0.1.0/PKG-INFO +15 -0
- blockmachine-0.1.0/__init__.py +1 -0
- blockmachine-0.1.0/blockmachine.egg-info/PKG-INFO +15 -0
- blockmachine-0.1.0/blockmachine.egg-info/SOURCES.txt +22 -0
- blockmachine-0.1.0/blockmachine.egg-info/dependency_links.txt +1 -0
- blockmachine-0.1.0/blockmachine.egg-info/entry_points.txt +3 -0
- blockmachine-0.1.0/blockmachine.egg-info/requires.txt +5 -0
- blockmachine-0.1.0/blockmachine.egg-info/top_level.txt +1 -0
- blockmachine-0.1.0/client.py +120 -0
- blockmachine-0.1.0/commands/__init__.py +1 -0
- blockmachine-0.1.0/commands/auth.py +220 -0
- blockmachine-0.1.0/commands/miner.py +587 -0
- blockmachine-0.1.0/config.py +127 -0
- blockmachine-0.1.0/main.py +22 -0
- blockmachine-0.1.0/pyproject.toml +33 -0
- blockmachine-0.1.0/settings.py +12 -0
- blockmachine-0.1.0/setup.cfg +4 -0
- blockmachine-0.1.0/utils.py +46 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blockmachine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BlockMachine CLI - Miner operator interface
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/taostat/blockmachine
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
12
|
+
Requires-Dist: rich>=13.7.0
|
|
13
|
+
Requires-Dist: httpx>=0.27.0
|
|
14
|
+
Requires-Dist: PyJWT>=2.9.0
|
|
15
|
+
Requires-Dist: cryptography>=44.0.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""BlockMachine CLI - Miner operator interface"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blockmachine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: BlockMachine CLI - Miner operator interface
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/taostat/blockmachine
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: typer[all]>=0.9.0
|
|
12
|
+
Requires-Dist: rich>=13.7.0
|
|
13
|
+
Requires-Dist: httpx>=0.27.0
|
|
14
|
+
Requires-Dist: PyJWT>=2.9.0
|
|
15
|
+
Requires-Dist: cryptography>=44.0.0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
__init__.py
|
|
2
|
+
client.py
|
|
3
|
+
config.py
|
|
4
|
+
main.py
|
|
5
|
+
pyproject.toml
|
|
6
|
+
settings.py
|
|
7
|
+
utils.py
|
|
8
|
+
./__init__.py
|
|
9
|
+
./client.py
|
|
10
|
+
./config.py
|
|
11
|
+
./main.py
|
|
12
|
+
./settings.py
|
|
13
|
+
./utils.py
|
|
14
|
+
blockmachine.egg-info/PKG-INFO
|
|
15
|
+
blockmachine.egg-info/SOURCES.txt
|
|
16
|
+
blockmachine.egg-info/dependency_links.txt
|
|
17
|
+
blockmachine.egg-info/entry_points.txt
|
|
18
|
+
blockmachine.egg-info/requires.txt
|
|
19
|
+
blockmachine.egg-info/top_level.txt
|
|
20
|
+
commands/__init__.py
|
|
21
|
+
commands/auth.py
|
|
22
|
+
commands/miner.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""HTTP client with auto-refresh for Registry API"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from cli.config import (
|
|
9
|
+
CLIConfig,
|
|
10
|
+
clear_token,
|
|
11
|
+
load_config,
|
|
12
|
+
save_config,
|
|
13
|
+
validate_token,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RegistryClient:
|
|
20
|
+
"""HTTP client for Registry API with auto-refresh on 401/403"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Optional[CLIConfig] = None):
|
|
23
|
+
self.config = config or load_config()
|
|
24
|
+
self._client = httpx.Client(timeout=30.0)
|
|
25
|
+
|
|
26
|
+
def _get_headers(self) -> dict[str, str]:
|
|
27
|
+
"""Get request headers including auth token if available"""
|
|
28
|
+
headers = {"Content-Type": "application/json"}
|
|
29
|
+
if self.config.access_token:
|
|
30
|
+
headers["Authorization"] = f"Bearer {self.config.access_token}"
|
|
31
|
+
return headers
|
|
32
|
+
|
|
33
|
+
def _refresh_token(self) -> bool:
|
|
34
|
+
"""Refresh the access token via /v1/oauth/refresh."""
|
|
35
|
+
if not self.config.refresh_token:
|
|
36
|
+
logger.warning("No refresh token available")
|
|
37
|
+
clear_token(self.config)
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from datetime import datetime, timedelta, timezone
|
|
42
|
+
|
|
43
|
+
response = self._client.post(
|
|
44
|
+
f"{self.config.auth_url}/v1/oauth/refresh",
|
|
45
|
+
json={
|
|
46
|
+
"grant_type": "refresh_token",
|
|
47
|
+
"refresh_token": self.config.refresh_token,
|
|
48
|
+
"client_id": self.config.client_id,
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
token_data = response.json()
|
|
53
|
+
|
|
54
|
+
access_token = token_data["access_token"]
|
|
55
|
+
claims = validate_token(access_token)
|
|
56
|
+
if claims is None:
|
|
57
|
+
logger.warning("Refreshed token failed validation")
|
|
58
|
+
clear_token(self.config)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
token_expires_in = token_data.get("expires_in", 3600)
|
|
62
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
63
|
+
seconds=token_expires_in
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.config.access_token = access_token
|
|
67
|
+
if "refresh_token" in token_data:
|
|
68
|
+
self.config.refresh_token = token_data["refresh_token"]
|
|
69
|
+
self.config.token_expires_at = expires_at.isoformat()
|
|
70
|
+
save_config(self.config)
|
|
71
|
+
|
|
72
|
+
logger.info("Token refreshed successfully")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.warning("Token refresh failed: %s", e)
|
|
77
|
+
clear_token(self.config)
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def _request(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
path: str,
|
|
84
|
+
json: Optional[dict] = None,
|
|
85
|
+
retry_auth: bool = True,
|
|
86
|
+
) -> httpx.Response:
|
|
87
|
+
"""Make an authenticated request with auto-refresh on 401."""
|
|
88
|
+
url = f"{self.config.api_url}{path}"
|
|
89
|
+
headers = self._get_headers()
|
|
90
|
+
|
|
91
|
+
response = self._client.request(method, url, json=json, headers=headers)
|
|
92
|
+
|
|
93
|
+
if response.status_code in (401, 403) and retry_auth:
|
|
94
|
+
logger.info("Token expired, attempting refresh...")
|
|
95
|
+
if self._refresh_token():
|
|
96
|
+
headers = self._get_headers()
|
|
97
|
+
response = self._client.request(method, url, json=json, headers=headers)
|
|
98
|
+
|
|
99
|
+
return response
|
|
100
|
+
|
|
101
|
+
def get(self, path: str) -> httpx.Response:
|
|
102
|
+
return self._request("GET", path)
|
|
103
|
+
|
|
104
|
+
def post(self, path: str, json: Optional[dict] = None) -> httpx.Response:
|
|
105
|
+
return self._request("POST", path, json=json)
|
|
106
|
+
|
|
107
|
+
def patch(self, path: str, json: Optional[dict] = None) -> httpx.Response:
|
|
108
|
+
return self._request("PATCH", path, json=json)
|
|
109
|
+
|
|
110
|
+
def delete(self, path: str) -> httpx.Response:
|
|
111
|
+
return self._request("DELETE", path)
|
|
112
|
+
|
|
113
|
+
def close(self) -> None:
|
|
114
|
+
self._client.close()
|
|
115
|
+
|
|
116
|
+
def __enter__(self):
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def __exit__(self, *args):
|
|
120
|
+
self.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules"""
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Authentication helpers for OAuth device flow"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import webbrowser
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.spinner import Spinner
|
|
11
|
+
|
|
12
|
+
from cli import settings
|
|
13
|
+
from cli.config import CLIConfig, load_config, save_config, validate_token
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def device_login(
|
|
19
|
+
scopes: list[str],
|
|
20
|
+
client_id: str = settings.CLIENT_ID,
|
|
21
|
+
auth_url: str = settings.AUTH_URL,
|
|
22
|
+
api_url: str | None = None,
|
|
23
|
+
no_browser: bool = False,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Run the OAuth device code flow with the given scopes."""
|
|
26
|
+
config = load_config()
|
|
27
|
+
config.client_id = client_id
|
|
28
|
+
config.auth_url = auth_url
|
|
29
|
+
if api_url:
|
|
30
|
+
config.api_url = api_url
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
with httpx.Client(timeout=30.0) as client:
|
|
34
|
+
response = client.post(
|
|
35
|
+
f"{auth_url}/v1/device/code",
|
|
36
|
+
json={"client_id": client_id, "scopes": scopes},
|
|
37
|
+
)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
device_data = response.json()
|
|
40
|
+
|
|
41
|
+
device_code = device_data["device_code"]
|
|
42
|
+
user_code = device_data["user_code"]
|
|
43
|
+
verification_uri = device_data["verification_uri"]
|
|
44
|
+
interval = device_data.get("interval", 5)
|
|
45
|
+
expires_in = device_data.get("expires_in", 900)
|
|
46
|
+
|
|
47
|
+
console.print()
|
|
48
|
+
console.print("[bold]To authenticate, visit:[/bold]")
|
|
49
|
+
console.print(f" [cyan]{verification_uri}[/cyan]")
|
|
50
|
+
console.print()
|
|
51
|
+
console.print(f"[bold]Enter code:[/bold] [yellow]{user_code}[/yellow]")
|
|
52
|
+
console.print()
|
|
53
|
+
|
|
54
|
+
if not no_browser:
|
|
55
|
+
try:
|
|
56
|
+
webbrowser.open(verification_uri)
|
|
57
|
+
console.print("[dim]Browser opened automatically.[/dim]")
|
|
58
|
+
except Exception:
|
|
59
|
+
console.print(
|
|
60
|
+
"[dim]Could not open browser. Please visit the URL above.[/dim]"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
_poll_for_token(
|
|
64
|
+
client,
|
|
65
|
+
config,
|
|
66
|
+
auth_url,
|
|
67
|
+
client_id,
|
|
68
|
+
device_code,
|
|
69
|
+
interval,
|
|
70
|
+
expires_in,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
except httpx.HTTPStatusError as e:
|
|
74
|
+
console.print(
|
|
75
|
+
f"[red]API error:[/red] {e.response.status_code} - {e.response.text}"
|
|
76
|
+
)
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
except typer.Exit:
|
|
79
|
+
raise
|
|
80
|
+
except Exception as e:
|
|
81
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _poll_for_token(
|
|
86
|
+
client: httpx.Client,
|
|
87
|
+
config: CLIConfig,
|
|
88
|
+
auth_url: str,
|
|
89
|
+
client_id: str,
|
|
90
|
+
device_code: str,
|
|
91
|
+
interval: int,
|
|
92
|
+
expires_in: int,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Poll the token endpoint until authorization completes."""
|
|
95
|
+
deadline = time.time() + expires_in
|
|
96
|
+
poll_interval = interval
|
|
97
|
+
|
|
98
|
+
with Live(
|
|
99
|
+
Spinner("dots", text="Waiting for browser authorization..."),
|
|
100
|
+
console=console,
|
|
101
|
+
transient=True,
|
|
102
|
+
):
|
|
103
|
+
while time.time() < deadline:
|
|
104
|
+
time.sleep(poll_interval)
|
|
105
|
+
|
|
106
|
+
token_response = client.post(
|
|
107
|
+
f"{auth_url}/v1/device/token",
|
|
108
|
+
json={
|
|
109
|
+
"client_id": client_id,
|
|
110
|
+
"device_code": device_code,
|
|
111
|
+
"grant_type": ("urn:ietf:params:oauth:grant-type:device_code"),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if token_response.status_code == 200:
|
|
116
|
+
_save_token_response(config, token_response.json())
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
error = _extract_error(token_response)
|
|
120
|
+
if error == "authorization_pending":
|
|
121
|
+
continue
|
|
122
|
+
if error == "slow_down":
|
|
123
|
+
poll_interval += 5
|
|
124
|
+
continue
|
|
125
|
+
if error == "expired_token":
|
|
126
|
+
console.print("\n[red]Login timed out.[/red] Please try again.")
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
if error == "access_denied":
|
|
129
|
+
console.print("\n[red]Login was denied.[/red]")
|
|
130
|
+
raise typer.Exit(1)
|
|
131
|
+
|
|
132
|
+
console.print(f"\n[red]Error:[/red] {error}")
|
|
133
|
+
raise typer.Exit(1)
|
|
134
|
+
|
|
135
|
+
console.print("\n[red]Login timed out.[/red] Please try again.")
|
|
136
|
+
raise typer.Exit(1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _save_token_response(config: CLIConfig, token_data: dict) -> None:
|
|
140
|
+
"""Validate and persist a token response."""
|
|
141
|
+
from datetime import datetime, timedelta, timezone
|
|
142
|
+
|
|
143
|
+
access_token = token_data["access_token"]
|
|
144
|
+
|
|
145
|
+
claims = validate_token(access_token)
|
|
146
|
+
if claims is None:
|
|
147
|
+
console.print(
|
|
148
|
+
"\n[red]Token validation failed.[/red]"
|
|
149
|
+
" The token issuer or signature is invalid."
|
|
150
|
+
)
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
|
|
153
|
+
token_expires_in = token_data.get("expires_in", 3600)
|
|
154
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=token_expires_in)
|
|
155
|
+
|
|
156
|
+
config.access_token = access_token
|
|
157
|
+
config.refresh_token = token_data.get("refresh_token")
|
|
158
|
+
config.token_expires_at = expires_at.isoformat()
|
|
159
|
+
save_config(config)
|
|
160
|
+
|
|
161
|
+
console.print("\n[green]Login successful![/green]")
|
|
162
|
+
exp_str = expires_at.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
163
|
+
console.print(f"[dim]Token expires: {exp_str}[/dim]")
|
|
164
|
+
|
|
165
|
+
scopes = claims.get("scopes", token_data.get("scopes", []))
|
|
166
|
+
if scopes:
|
|
167
|
+
console.print(f"[dim]Scopes: {', '.join(scopes)}[/dim]")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _extract_error(response: httpx.Response) -> str:
|
|
171
|
+
"""Extract error string from a token endpoint response."""
|
|
172
|
+
try:
|
|
173
|
+
data = response.json()
|
|
174
|
+
return data.get("error_description") or data.get("error", "")
|
|
175
|
+
except Exception:
|
|
176
|
+
return f"HTTP {response.status_code}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_status() -> None:
|
|
180
|
+
"""Print current authentication status."""
|
|
181
|
+
from cli.config import is_token_valid
|
|
182
|
+
|
|
183
|
+
config = load_config()
|
|
184
|
+
|
|
185
|
+
console.print(f"[bold]API URL:[/bold] {config.api_url}")
|
|
186
|
+
console.print(f"[bold]Auth provider:[/bold] {config.auth_url}")
|
|
187
|
+
console.print(f"[bold]Client ID:[/bold] {config.client_id}")
|
|
188
|
+
|
|
189
|
+
if config.access_token:
|
|
190
|
+
if is_token_valid(config):
|
|
191
|
+
claims = validate_token(config.access_token)
|
|
192
|
+
if claims:
|
|
193
|
+
console.print(
|
|
194
|
+
f"[green]Token valid[/green]"
|
|
195
|
+
f" (issuer: {claims.get('iss', 'unknown')})"
|
|
196
|
+
)
|
|
197
|
+
scopes = claims.get("scopes", [])
|
|
198
|
+
if scopes:
|
|
199
|
+
console.print(f"[dim]Scopes: {', '.join(scopes)}[/dim]")
|
|
200
|
+
sub = claims.get("sub")
|
|
201
|
+
if sub:
|
|
202
|
+
console.print(f"[dim]Subject: {sub}[/dim]")
|
|
203
|
+
else:
|
|
204
|
+
console.print("[yellow]Token expired or invalid[/yellow]")
|
|
205
|
+
|
|
206
|
+
if config.refresh_token:
|
|
207
|
+
console.print("[dim]Refresh token: available[/dim]")
|
|
208
|
+
else:
|
|
209
|
+
console.print("[dim]Refresh token: not available[/dim]")
|
|
210
|
+
else:
|
|
211
|
+
console.print("[red]Not logged in[/red]")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def do_logout() -> None:
|
|
215
|
+
"""Clear stored authentication tokens."""
|
|
216
|
+
from cli.config import clear_token
|
|
217
|
+
|
|
218
|
+
config = load_config()
|
|
219
|
+
clear_token(config)
|
|
220
|
+
console.print("[green]Logged out[/green]")
|
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Miner commands: node registration, secrets, and pricing"""
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from cli.client import RegistryClient
|
|
12
|
+
from cli import settings
|
|
13
|
+
from cli.commands.auth import check_status, device_login, do_logout
|
|
14
|
+
from cli.config import load_config, save_config
|
|
15
|
+
from cli.utils import format_timestamp, resolve_node_id
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Miner node management")
|
|
18
|
+
secret_app = typer.Typer(help="Secret management")
|
|
19
|
+
price_app = typer.Typer(help="Pricing management")
|
|
20
|
+
|
|
21
|
+
app.add_typer(secret_app, name="secret")
|
|
22
|
+
app.add_typer(price_app, name="price")
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
CHAIN = "tao"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_alias(alias: Optional[str]) -> str:
|
|
30
|
+
"""Resolve alias from argument or active node context."""
|
|
31
|
+
if alias:
|
|
32
|
+
return alias
|
|
33
|
+
config = load_config()
|
|
34
|
+
if config.active_miner_node:
|
|
35
|
+
return config.active_miner_node
|
|
36
|
+
console.print(
|
|
37
|
+
"[red]No node specified and no active node set.[/red]\n"
|
|
38
|
+
"Run 'bm miner use <alias>' first."
|
|
39
|
+
)
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_node(client: RegistryClient, id_or_alias: str) -> str:
|
|
44
|
+
"""Resolve an alias or ID to a node UUID."""
|
|
45
|
+
response = client.get("/nodes")
|
|
46
|
+
if response.status_code != 200:
|
|
47
|
+
console.print(f"[red]Error:[/red] {response.status_code}")
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
node_id = resolve_node_id(response.json(), id_or_alias)
|
|
50
|
+
if not node_id:
|
|
51
|
+
console.print(f"[red]Node not found:[/red] {id_or_alias}")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
return node_id
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Auth ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command("login")
|
|
60
|
+
def login(
|
|
61
|
+
client_id: str = typer.Option(
|
|
62
|
+
settings.CLIENT_ID, "--client-id", help="OAuth client ID"
|
|
63
|
+
),
|
|
64
|
+
auth_url: str = typer.Option(
|
|
65
|
+
settings.AUTH_URL, "--auth-url", help="TaoStats auth URL"
|
|
66
|
+
),
|
|
67
|
+
api_url: str = typer.Option(None, "--api-url", "-u", help="Registry API URL"),
|
|
68
|
+
no_browser: bool = typer.Option(
|
|
69
|
+
False, "--no-browser", help="Don't auto-open browser"
|
|
70
|
+
),
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Login with miner scopes."""
|
|
73
|
+
device_login(
|
|
74
|
+
scopes=settings.MINER_SCOPES,
|
|
75
|
+
client_id=client_id,
|
|
76
|
+
auth_url=auth_url,
|
|
77
|
+
api_url=api_url,
|
|
78
|
+
no_browser=no_browser,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("status")
|
|
83
|
+
def status() -> None:
|
|
84
|
+
"""Check authentication status."""
|
|
85
|
+
check_status()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("logout")
|
|
89
|
+
def logout() -> None:
|
|
90
|
+
"""Clear stored authentication tokens."""
|
|
91
|
+
do_logout()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Node CRUD ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("add")
|
|
98
|
+
def add(
|
|
99
|
+
endpoint: str = typer.Option(None, "--endpoint", "-e", help="WSS endpoint URL"),
|
|
100
|
+
alias: str = typer.Option(None, "--alias", "-a", help="Friendly name"),
|
|
101
|
+
secret: str = typer.Option(None, "--secret", "-s", help="Bearer token secret"),
|
|
102
|
+
price: str = typer.Option(None, "--price", "-p", help="USD per compute unit"),
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Register a new miner node (interactive if flags omitted)."""
|
|
105
|
+
if not endpoint:
|
|
106
|
+
endpoint = typer.prompt("Endpoint (wss://...)")
|
|
107
|
+
if not alias:
|
|
108
|
+
default = _default_alias(endpoint)
|
|
109
|
+
alias = typer.prompt("Alias", default=default)
|
|
110
|
+
if not secret:
|
|
111
|
+
secret = getpass.getpass("Secret: ")
|
|
112
|
+
if not price:
|
|
113
|
+
price = typer.prompt("Price (USD/CU)")
|
|
114
|
+
|
|
115
|
+
if len(secret) < 16:
|
|
116
|
+
console.print("[red]Secret must be at least 16 characters[/red]")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
|
|
119
|
+
config = load_config()
|
|
120
|
+
with RegistryClient(config) as client:
|
|
121
|
+
node_id = _create_node(client, endpoint, alias)
|
|
122
|
+
_set_node_secret(client, node_id, secret)
|
|
123
|
+
_set_node_price(client, node_id, price)
|
|
124
|
+
|
|
125
|
+
config.active_miner_node = alias
|
|
126
|
+
save_config(config)
|
|
127
|
+
|
|
128
|
+
console.print(f"\n[dim]Active node set to '{alias}'[/dim]")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _default_alias(endpoint: str) -> str:
|
|
132
|
+
"""Derive a default alias from an endpoint URL."""
|
|
133
|
+
host = endpoint.replace("wss://", "").replace("ws://", "")
|
|
134
|
+
host = host.split(":")[0].split("/")[0]
|
|
135
|
+
return f"tao-{host.replace('.', '-')}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _create_node(client: RegistryClient, endpoint: str, alias: str) -> str:
|
|
139
|
+
"""Register a node and return its ID."""
|
|
140
|
+
response = client.post(
|
|
141
|
+
"/nodes",
|
|
142
|
+
json={"endpoint": endpoint, "chain": CHAIN, "alias": alias},
|
|
143
|
+
)
|
|
144
|
+
if response.status_code == 201:
|
|
145
|
+
data = response.json()
|
|
146
|
+
console.print(f"[green]Node registered:[/green] {data['id'][:8]}")
|
|
147
|
+
return data["id"]
|
|
148
|
+
if response.status_code == 401:
|
|
149
|
+
console.print("[red]Not authenticated.[/red] Run 'bm miner login' first.")
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
if response.status_code == 409:
|
|
152
|
+
detail = response.json().get("detail", "Alias already exists")
|
|
153
|
+
console.print(f"[red]Conflict:[/red] {detail}")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _set_node_secret(client: RegistryClient, node_id: str, secret: str) -> None:
|
|
160
|
+
"""Set the secret on a node."""
|
|
161
|
+
response = client.post(
|
|
162
|
+
f"/nodes/{node_id}/secret",
|
|
163
|
+
json={"secret": secret, "rotate": False},
|
|
164
|
+
)
|
|
165
|
+
if response.status_code == 201:
|
|
166
|
+
console.print("[green]Secret set[/green]")
|
|
167
|
+
return
|
|
168
|
+
console.print(
|
|
169
|
+
f"[red]Failed to set secret:[/red] {response.status_code} - {response.text}"
|
|
170
|
+
)
|
|
171
|
+
raise typer.Exit(1)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _set_node_price(client: RegistryClient, node_id: str, price: str) -> None:
|
|
175
|
+
"""Set the price on a node."""
|
|
176
|
+
epoch_length_sec = 300 * 12
|
|
177
|
+
epoch = int(time.time() / epoch_length_sec) + 1
|
|
178
|
+
|
|
179
|
+
response = client.post(
|
|
180
|
+
f"/nodes/{node_id}/price",
|
|
181
|
+
json={
|
|
182
|
+
"target_usd_per_cu": price,
|
|
183
|
+
"effective_from_epoch": epoch,
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
if response.status_code == 201:
|
|
187
|
+
data = response.json()
|
|
188
|
+
console.print(
|
|
189
|
+
f"[green]Price set:[/green]"
|
|
190
|
+
f" {data['target_usd_per_cu']} USD/CU"
|
|
191
|
+
f" (epoch {data['effective_from_epoch']})"
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
console.print(
|
|
195
|
+
f"[red]Failed to set price:[/red] {response.status_code} - {response.text}"
|
|
196
|
+
)
|
|
197
|
+
raise typer.Exit(1)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command("use")
|
|
201
|
+
def use(
|
|
202
|
+
alias: str = typer.Argument(..., help="Node alias to set as active"),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Set the active miner node for subsequent commands."""
|
|
205
|
+
config = load_config()
|
|
206
|
+
config.active_miner_node = alias
|
|
207
|
+
save_config(config)
|
|
208
|
+
console.print(f"Active miner node: [bold]{alias}[/bold]")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command("ls")
|
|
212
|
+
def ls() -> None:
|
|
213
|
+
"""List all miner nodes."""
|
|
214
|
+
config = load_config()
|
|
215
|
+
with RegistryClient(config) as client:
|
|
216
|
+
response = client.get("/nodes")
|
|
217
|
+
|
|
218
|
+
if response.status_code == 401:
|
|
219
|
+
console.print("[red]Not authenticated.[/red] Run 'bm miner login' first.")
|
|
220
|
+
raise typer.Exit(1)
|
|
221
|
+
if response.status_code != 200:
|
|
222
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
nodes = response.json().get("nodes", [])
|
|
226
|
+
if not nodes:
|
|
227
|
+
console.print("[dim]No nodes registered[/dim]")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
active = config.active_miner_node
|
|
231
|
+
table = Table(title="Miner Nodes")
|
|
232
|
+
table.add_column("", width=1)
|
|
233
|
+
table.add_column("Alias")
|
|
234
|
+
table.add_column("Chain")
|
|
235
|
+
table.add_column("Status")
|
|
236
|
+
table.add_column("Endpoint")
|
|
237
|
+
table.add_column("Last Seen")
|
|
238
|
+
|
|
239
|
+
for node in nodes:
|
|
240
|
+
status_color = {
|
|
241
|
+
"active": "green",
|
|
242
|
+
"pending": "yellow",
|
|
243
|
+
"unreachable": "red",
|
|
244
|
+
"suspended": "red",
|
|
245
|
+
}.get(node["status"], "white")
|
|
246
|
+
marker = "*" if node.get("alias") == active else ""
|
|
247
|
+
|
|
248
|
+
table.add_row(
|
|
249
|
+
marker,
|
|
250
|
+
node.get("alias") or node["id"][:8],
|
|
251
|
+
node["chain"],
|
|
252
|
+
f"[{status_color}]{node['status']}[/{status_color}]",
|
|
253
|
+
_truncate(node["endpoint"], 40),
|
|
254
|
+
format_timestamp(node.get("last_seen_at")),
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
console.print(table)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _truncate(text: str, length: int) -> str:
|
|
261
|
+
if len(text) <= length:
|
|
262
|
+
return text
|
|
263
|
+
return text[:length] + "..."
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@app.command("show")
|
|
267
|
+
def show(
|
|
268
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Show details of a miner node."""
|
|
271
|
+
alias = _get_alias(alias)
|
|
272
|
+
config = load_config()
|
|
273
|
+
|
|
274
|
+
with RegistryClient(config) as client:
|
|
275
|
+
node_id = _resolve_node(client, alias)
|
|
276
|
+
response = client.get(f"/nodes/{node_id}")
|
|
277
|
+
|
|
278
|
+
if response.status_code == 404:
|
|
279
|
+
console.print(f"[red]Node not found:[/red] {alias}")
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
if response.status_code != 200:
|
|
282
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
283
|
+
raise typer.Exit(1)
|
|
284
|
+
|
|
285
|
+
node = response.json()
|
|
286
|
+
console.print(f"[bold]ID:[/bold] {node['id']}")
|
|
287
|
+
console.print(f"[bold]Alias:[/bold] {node.get('alias') or '-'}")
|
|
288
|
+
console.print(f"[bold]Chain:[/bold] {node['chain']}")
|
|
289
|
+
console.print(f"[bold]Status:[/bold] {node['status']}")
|
|
290
|
+
console.print(f"[bold]Endpoint:[/bold] {node['endpoint']}")
|
|
291
|
+
console.print(f"[bold]Created:[/bold] {format_timestamp(node['created_at'])}")
|
|
292
|
+
console.print(
|
|
293
|
+
f"[bold]Last Seen:[/bold] {format_timestamp(node.get('last_seen_at'))}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@app.command("rm")
|
|
298
|
+
def rm(
|
|
299
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
300
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Remove a miner node."""
|
|
303
|
+
alias = _get_alias(alias)
|
|
304
|
+
config = load_config()
|
|
305
|
+
|
|
306
|
+
with RegistryClient(config) as client:
|
|
307
|
+
node_id = _resolve_node(client, alias)
|
|
308
|
+
|
|
309
|
+
if not force:
|
|
310
|
+
confirm = typer.confirm(f"Delete node {alias}?")
|
|
311
|
+
if not confirm:
|
|
312
|
+
console.print("[dim]Cancelled[/dim]")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
response = client.delete(f"/nodes/{node_id}")
|
|
316
|
+
|
|
317
|
+
if response.status_code == 204:
|
|
318
|
+
console.print("[green]Node deleted[/green]")
|
|
319
|
+
if config.active_miner_node == alias:
|
|
320
|
+
config.active_miner_node = None
|
|
321
|
+
save_config(config)
|
|
322
|
+
elif response.status_code == 404:
|
|
323
|
+
console.print(f"[red]Node not found:[/red] {alias}")
|
|
324
|
+
raise typer.Exit(1)
|
|
325
|
+
else:
|
|
326
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
327
|
+
raise typer.Exit(1)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@app.command("update")
|
|
331
|
+
def update(
|
|
332
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
333
|
+
new_alias: str = typer.Option(None, "--alias", "-a", help="New alias"),
|
|
334
|
+
endpoint: str = typer.Option(None, "--endpoint", "-e", help="New endpoint"),
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Update a node's alias or endpoint."""
|
|
337
|
+
alias = _get_alias(alias)
|
|
338
|
+
|
|
339
|
+
if not new_alias and not endpoint:
|
|
340
|
+
console.print(
|
|
341
|
+
"[yellow]Nothing to update. Specify --alias or --endpoint[/yellow]"
|
|
342
|
+
)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
config = load_config()
|
|
346
|
+
update_data: dict = {}
|
|
347
|
+
if new_alias:
|
|
348
|
+
update_data["alias"] = new_alias
|
|
349
|
+
if endpoint:
|
|
350
|
+
update_data["endpoint"] = endpoint
|
|
351
|
+
|
|
352
|
+
with RegistryClient(config) as client:
|
|
353
|
+
node_id = _resolve_node(client, alias)
|
|
354
|
+
response = client.patch(f"/nodes/{node_id}", json=update_data)
|
|
355
|
+
|
|
356
|
+
if response.status_code == 200:
|
|
357
|
+
console.print("[green]Node updated[/green]")
|
|
358
|
+
if new_alias and config.active_miner_node == alias:
|
|
359
|
+
config.active_miner_node = new_alias
|
|
360
|
+
save_config(config)
|
|
361
|
+
elif response.status_code == 409:
|
|
362
|
+
detail = response.json().get("detail", "Alias already exists")
|
|
363
|
+
console.print(f"[red]Conflict:[/red] {detail}")
|
|
364
|
+
raise typer.Exit(1)
|
|
365
|
+
else:
|
|
366
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ── Secrets ──────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@secret_app.command("set")
|
|
374
|
+
def secret_set(
|
|
375
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
376
|
+
secret: str = typer.Option(
|
|
377
|
+
None,
|
|
378
|
+
"--secret",
|
|
379
|
+
"-s",
|
|
380
|
+
help="Secret value (will prompt if not provided)",
|
|
381
|
+
),
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Set the bearer token secret for a node."""
|
|
384
|
+
alias = _get_alias(alias)
|
|
385
|
+
|
|
386
|
+
if not secret:
|
|
387
|
+
secret = getpass.getpass("Enter secret: ")
|
|
388
|
+
confirm = getpass.getpass("Confirm secret: ")
|
|
389
|
+
if secret != confirm:
|
|
390
|
+
console.print("[red]Secrets do not match[/red]")
|
|
391
|
+
raise typer.Exit(1)
|
|
392
|
+
|
|
393
|
+
if len(secret) < 16:
|
|
394
|
+
console.print("[red]Secret must be at least 16 characters[/red]")
|
|
395
|
+
raise typer.Exit(1)
|
|
396
|
+
|
|
397
|
+
config = load_config()
|
|
398
|
+
with RegistryClient(config) as client:
|
|
399
|
+
node_id = _resolve_node(client, alias)
|
|
400
|
+
response = client.post(
|
|
401
|
+
f"/nodes/{node_id}/secret",
|
|
402
|
+
json={"secret": secret, "rotate": False},
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if response.status_code == 201:
|
|
406
|
+
data = response.json()
|
|
407
|
+
console.print("[green]Secret set[/green]")
|
|
408
|
+
console.print(f"[bold]Version:[/bold] {data['version']}")
|
|
409
|
+
else:
|
|
410
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@secret_app.command("show")
|
|
415
|
+
def secret_show(
|
|
416
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
417
|
+
) -> None:
|
|
418
|
+
"""Show secret metadata (not the secret itself)."""
|
|
419
|
+
alias = _get_alias(alias)
|
|
420
|
+
config = load_config()
|
|
421
|
+
|
|
422
|
+
with RegistryClient(config) as client:
|
|
423
|
+
node_id = _resolve_node(client, alias)
|
|
424
|
+
response = client.get(f"/nodes/{node_id}/secret")
|
|
425
|
+
|
|
426
|
+
if response.status_code != 200:
|
|
427
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
428
|
+
raise typer.Exit(1)
|
|
429
|
+
|
|
430
|
+
secrets = response.json().get("secrets", [])
|
|
431
|
+
if not secrets:
|
|
432
|
+
console.print("[dim]No secrets configured[/dim]")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
table = Table(title="Secrets")
|
|
436
|
+
table.add_column("Version")
|
|
437
|
+
table.add_column("State")
|
|
438
|
+
table.add_column("Created")
|
|
439
|
+
|
|
440
|
+
for s in secrets:
|
|
441
|
+
color = {"active": "green", "next": "yellow", "retired": "dim"}.get(
|
|
442
|
+
s["state"], "white"
|
|
443
|
+
)
|
|
444
|
+
table.add_row(
|
|
445
|
+
str(s["version"]),
|
|
446
|
+
f"[{color}]{s['state']}[/{color}]",
|
|
447
|
+
format_timestamp(s["created_at"]),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
console.print(table)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@secret_app.command("promote")
|
|
454
|
+
def secret_promote(
|
|
455
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
456
|
+
) -> None:
|
|
457
|
+
"""Promote the 'next' secret to 'active'."""
|
|
458
|
+
alias = _get_alias(alias)
|
|
459
|
+
config = load_config()
|
|
460
|
+
|
|
461
|
+
with RegistryClient(config) as client:
|
|
462
|
+
node_id = _resolve_node(client, alias)
|
|
463
|
+
response = client.post(f"/nodes/{node_id}/secret/promote")
|
|
464
|
+
|
|
465
|
+
if response.status_code == 200:
|
|
466
|
+
data = response.json()
|
|
467
|
+
console.print("[green]Secret promoted[/green]")
|
|
468
|
+
console.print(f"[bold]Version:[/bold] {data['version']}")
|
|
469
|
+
else:
|
|
470
|
+
detail = response.json().get("detail", response.text)
|
|
471
|
+
console.print(f"[red]Error:[/red] {detail}")
|
|
472
|
+
raise typer.Exit(1)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# ── Pricing ──────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@price_app.command("set")
|
|
479
|
+
def price_set(
|
|
480
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
481
|
+
price: str = typer.Option(..., "--price", "-p", help="Target USD per compute unit"),
|
|
482
|
+
epoch: int = typer.Option(
|
|
483
|
+
None,
|
|
484
|
+
"--epoch",
|
|
485
|
+
"-e",
|
|
486
|
+
help="Effective from epoch (default: next epoch)",
|
|
487
|
+
),
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Set a price for a node."""
|
|
490
|
+
alias = _get_alias(alias)
|
|
491
|
+
|
|
492
|
+
if epoch is None:
|
|
493
|
+
epoch_length_sec = 300 * 12
|
|
494
|
+
epoch = int(time.time() / epoch_length_sec) + 1
|
|
495
|
+
|
|
496
|
+
config = load_config()
|
|
497
|
+
with RegistryClient(config) as client:
|
|
498
|
+
node_id = _resolve_node(client, alias)
|
|
499
|
+
response = client.post(
|
|
500
|
+
f"/nodes/{node_id}/price",
|
|
501
|
+
json={
|
|
502
|
+
"target_usd_per_cu": price,
|
|
503
|
+
"effective_from_epoch": epoch,
|
|
504
|
+
},
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if response.status_code == 201:
|
|
508
|
+
data = response.json()
|
|
509
|
+
console.print(
|
|
510
|
+
f"[green]Price set:[/green]"
|
|
511
|
+
f" {data['target_usd_per_cu']} USD/CU"
|
|
512
|
+
f" (epoch {data['effective_from_epoch']})"
|
|
513
|
+
)
|
|
514
|
+
elif response.status_code == 400:
|
|
515
|
+
detail = response.json().get("detail", "Invalid request")
|
|
516
|
+
console.print(f"[red]Error:[/red] {detail}")
|
|
517
|
+
raise typer.Exit(1)
|
|
518
|
+
else:
|
|
519
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
520
|
+
raise typer.Exit(1)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@price_app.command("show")
|
|
524
|
+
def price_show(
|
|
525
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
526
|
+
) -> None:
|
|
527
|
+
"""Show the current price for a node."""
|
|
528
|
+
alias = _get_alias(alias)
|
|
529
|
+
config = load_config()
|
|
530
|
+
|
|
531
|
+
with RegistryClient(config) as client:
|
|
532
|
+
node_id = _resolve_node(client, alias)
|
|
533
|
+
response = client.get(f"/nodes/{node_id}/price")
|
|
534
|
+
|
|
535
|
+
if response.status_code == 200:
|
|
536
|
+
data = response.json()
|
|
537
|
+
console.print(f"[bold]Price:[/bold] {data['target_usd_per_cu']} USD/CU")
|
|
538
|
+
console.print(
|
|
539
|
+
f"[bold]Effective from epoch:[/bold] {data['effective_from_epoch']}"
|
|
540
|
+
)
|
|
541
|
+
console.print(f"[bold]Set at:[/bold] {format_timestamp(data['created_at'])}")
|
|
542
|
+
elif response.status_code == 404:
|
|
543
|
+
detail = response.json().get("detail", "Not found")
|
|
544
|
+
if "No price" in detail:
|
|
545
|
+
console.print("[dim]No price set for this node[/dim]")
|
|
546
|
+
else:
|
|
547
|
+
console.print(f"[red]Node not found:[/red] {alias}")
|
|
548
|
+
raise typer.Exit(1)
|
|
549
|
+
else:
|
|
550
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
551
|
+
raise typer.Exit(1)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@price_app.command("history")
|
|
555
|
+
def price_history(
|
|
556
|
+
alias: str = typer.Argument(None, help="Node alias or ID"),
|
|
557
|
+
) -> None:
|
|
558
|
+
"""Show price history for a node."""
|
|
559
|
+
alias = _get_alias(alias)
|
|
560
|
+
config = load_config()
|
|
561
|
+
|
|
562
|
+
with RegistryClient(config) as client:
|
|
563
|
+
node_id = _resolve_node(client, alias)
|
|
564
|
+
response = client.get(f"/nodes/{node_id}/price/history")
|
|
565
|
+
|
|
566
|
+
if response.status_code != 200:
|
|
567
|
+
console.print(f"[red]Error:[/red] {response.status_code} - {response.text}")
|
|
568
|
+
raise typer.Exit(1)
|
|
569
|
+
|
|
570
|
+
prices = response.json().get("prices", [])
|
|
571
|
+
if not prices:
|
|
572
|
+
console.print("[dim]No price history[/dim]")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
table = Table(title="Price History")
|
|
576
|
+
table.add_column("Epoch")
|
|
577
|
+
table.add_column("Price (USD/CU)")
|
|
578
|
+
table.add_column("Set At")
|
|
579
|
+
|
|
580
|
+
for p in prices:
|
|
581
|
+
table.add_row(
|
|
582
|
+
str(p["effective_from_epoch"]),
|
|
583
|
+
p["target_usd_per_cu"],
|
|
584
|
+
format_timestamp(p["created_at"]),
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
console.print(table)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""CLI configuration management — user state persisted to disk."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import jwt
|
|
11
|
+
|
|
12
|
+
from cli import settings
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_jwks_client: jwt.PyJWKClient | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_jwks_client() -> jwt.PyJWKClient:
|
|
20
|
+
global _jwks_client
|
|
21
|
+
if _jwks_client is None:
|
|
22
|
+
_jwks_client = jwt.PyJWKClient(settings.JWKS_URI)
|
|
23
|
+
return _jwks_client
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CLIConfig:
|
|
28
|
+
"""User state stored in ~/.blockmachine/config.json"""
|
|
29
|
+
|
|
30
|
+
api_url: str = settings.API_URL
|
|
31
|
+
access_token: Optional[str] = None
|
|
32
|
+
refresh_token: Optional[str] = None
|
|
33
|
+
token_expires_at: Optional[str] = None
|
|
34
|
+
auth_url: str = settings.AUTH_URL
|
|
35
|
+
client_id: str = settings.CLIENT_ID
|
|
36
|
+
active_miner_node: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_config_dir() -> Path:
|
|
40
|
+
return Path.home() / ".blockmachine"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_config_path() -> Path:
|
|
44
|
+
return get_config_dir() / "config.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config() -> CLIConfig:
|
|
48
|
+
"""Load user config from disk, falling back to settings defaults."""
|
|
49
|
+
config_path = get_config_path()
|
|
50
|
+
|
|
51
|
+
if not config_path.exists():
|
|
52
|
+
return CLIConfig()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
with open(config_path) as f:
|
|
56
|
+
data = json.load(f)
|
|
57
|
+
return CLIConfig(
|
|
58
|
+
api_url=data.get("api_url", settings.API_URL),
|
|
59
|
+
access_token=(data.get("access_token") or data.get("token")),
|
|
60
|
+
refresh_token=data.get("refresh_token"),
|
|
61
|
+
token_expires_at=data.get("token_expires_at"),
|
|
62
|
+
auth_url=data.get("auth_url", settings.AUTH_URL),
|
|
63
|
+
client_id=data.get("client_id", settings.CLIENT_ID),
|
|
64
|
+
active_miner_node=data.get("active_miner_node"),
|
|
65
|
+
)
|
|
66
|
+
except (json.JSONDecodeError, IOError):
|
|
67
|
+
return CLIConfig()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_config(config: CLIConfig) -> None:
|
|
71
|
+
config_dir = get_config_dir()
|
|
72
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
config_path = get_config_path()
|
|
75
|
+
with open(config_path, "w") as f:
|
|
76
|
+
json.dump(asdict(config), f, indent=2)
|
|
77
|
+
|
|
78
|
+
os.chmod(config_path, 0o600)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _fetch_signing_key(token: str) -> jwt.PyJWK:
|
|
82
|
+
"""Fetch the signing key, retrying with a fresh JWKS client on failure."""
|
|
83
|
+
global _jwks_client
|
|
84
|
+
try:
|
|
85
|
+
return _get_jwks_client().get_signing_key_from_jwt(token)
|
|
86
|
+
except jwt.PyJWKClientError:
|
|
87
|
+
logger.info("JWKS fetch failed, refreshing cached client")
|
|
88
|
+
_jwks_client = None
|
|
89
|
+
return _get_jwks_client().get_signing_key_from_jwt(token)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_token(token: str) -> dict | None:
|
|
93
|
+
"""Validate a JWT against the JWKS and expected issuer."""
|
|
94
|
+
try:
|
|
95
|
+
signing_key = _fetch_signing_key(token)
|
|
96
|
+
return jwt.decode(
|
|
97
|
+
token,
|
|
98
|
+
signing_key.key,
|
|
99
|
+
algorithms=["RS256"],
|
|
100
|
+
issuer=settings.AUTH_URL,
|
|
101
|
+
audience=settings.AUDIENCE,
|
|
102
|
+
options={"require": ["exp", "iss", "sub"]},
|
|
103
|
+
)
|
|
104
|
+
except jwt.ExpiredSignatureError:
|
|
105
|
+
logger.debug("Token expired (JWT exp claim)")
|
|
106
|
+
return None
|
|
107
|
+
except jwt.InvalidIssuerError:
|
|
108
|
+
logger.warning("Token issuer mismatch")
|
|
109
|
+
return None
|
|
110
|
+
except jwt.PyJWTError as e:
|
|
111
|
+
logger.warning("JWT validation failed: %s", e)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_token_valid(config: CLIConfig) -> bool:
|
|
116
|
+
"""Check if the stored token is valid via JWT signature + issuer."""
|
|
117
|
+
if not config.access_token:
|
|
118
|
+
return False
|
|
119
|
+
return validate_token(config.access_token) is not None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clear_token(config: CLIConfig) -> CLIConfig:
|
|
123
|
+
config.access_token = None
|
|
124
|
+
config.refresh_token = None
|
|
125
|
+
config.token_expires_at = None
|
|
126
|
+
save_config(config)
|
|
127
|
+
return config
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""BlockMachine CLI main entrypoint"""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from cli.commands.miner import app as miner_app
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(
|
|
8
|
+
name="bm",
|
|
9
|
+
help="BlockMachine CLI",
|
|
10
|
+
no_args_is_help=True,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
app.add_typer(miner_app, name="miner", help="Miner node management")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
"""Main entry point"""
|
|
18
|
+
app()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
main()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "blockmachine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "BlockMachine CLI - Miner operator interface"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"typer[all]>=0.9.0",
|
|
18
|
+
"rich>=13.7.0",
|
|
19
|
+
"httpx>=0.27.0",
|
|
20
|
+
"PyJWT>=2.9.0",
|
|
21
|
+
"cryptography>=44.0.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/taostat/blockmachine"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
bm = "cli.main:main"
|
|
29
|
+
blockmachine = "cli.main:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["cli", "cli.commands"]
|
|
33
|
+
package-dir = {"cli" = ".", "cli.commands" = "commands"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Application defaults with environment variable overrides."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
AUTH_URL = os.environ.get("BM_AUTH_URL", "https://test-auth.taostats.io")
|
|
6
|
+
API_URL = os.environ.get("BM_API_URL", "https://api.blockmachine.io")
|
|
7
|
+
CLIENT_ID = os.environ.get("BM_CLIENT_ID", "07f5c729-5ca7-412a-b5e7-4966e132548e")
|
|
8
|
+
AUDIENCE = "bittensor-apps"
|
|
9
|
+
JWKS_URI = f"{AUTH_URL}/.well-known/jwks.json"
|
|
10
|
+
|
|
11
|
+
MINER_SCOPES = ["subnet:19:miner"]
|
|
12
|
+
VALIDATOR_SCOPES = ["subnet:19:validator"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""CLI utility functions"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_node_id(db_response: dict, id_or_alias: str) -> Optional[str]:
|
|
10
|
+
"""
|
|
11
|
+
Resolve a node ID or alias to a node ID.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
db_response: Response from nodes list API
|
|
15
|
+
id_or_alias: Either a node UUID or an alias
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Node UUID string or None if not found
|
|
19
|
+
"""
|
|
20
|
+
nodes = db_response.get("nodes", [])
|
|
21
|
+
|
|
22
|
+
# First try exact ID match
|
|
23
|
+
for node in nodes:
|
|
24
|
+
if str(node.get("id")) == id_or_alias:
|
|
25
|
+
return str(node["id"])
|
|
26
|
+
|
|
27
|
+
# Then try alias match
|
|
28
|
+
for node in nodes:
|
|
29
|
+
if node.get("alias") == id_or_alias:
|
|
30
|
+
return str(node["id"])
|
|
31
|
+
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def format_timestamp(iso_string: Optional[str]) -> str:
|
|
36
|
+
"""Format an ISO timestamp for display"""
|
|
37
|
+
if not iso_string:
|
|
38
|
+
return "N/A"
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
|
|
43
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
44
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
45
|
+
except (ValueError, AttributeError):
|
|
46
|
+
return iso_string
|