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.
@@ -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,3 @@
1
+ [console_scripts]
2
+ blockmachine = cli.main:main
3
+ bm = cli.main:main
@@ -0,0 +1,5 @@
1
+ typer[all]>=0.9.0
2
+ rich>=13.7.0
3
+ httpx>=0.27.0
4
+ PyJWT>=2.9.0
5
+ cryptography>=44.0.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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