ground-control-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- client/__init__.py +1 -0
- client/claude.py +240 -0
- client/cli.py +314 -0
- client/connect.py +128 -0
- client/machine.py +254 -0
- client/probes/__init__.py +57 -0
- client/probes/code/__init__.py +435 -0
- client/probes/code/bash.py +163 -0
- client/probes/code/javascript.js +171 -0
- client/probes/code/javascript.py +193 -0
- client/probes/code/python.py +143 -0
- client/probes/errors.py +67 -0
- client/probes/gh.py +126 -0
- client/probes/git.py +280 -0
- client/probes/health.py +128 -0
- client/probes/settings.py +72 -0
- client/probes/tests.py +532 -0
- client/repos.py +68 -0
- client/service.py +398 -0
- client/system/__init__.py +73 -0
- client/system/macos.py +92 -0
- client/update.py +110 -0
- ground_control_cli-0.2.0.dist-info/METADATA +5 -0
- ground_control_cli-0.2.0.dist-info/RECORD +26 -0
- ground_control_cli-0.2.0.dist-info/WHEEL +4 -0
- ground_control_cli-0.2.0.dist-info/entry_points.txt +3 -0
client/service.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""The plugin's calls to the service: a step's checks, and a feature's plan, all decided there.
|
|
2
|
+
|
|
3
|
+
The client decides nothing. To enter or exit a step it asks the service to run the step's
|
|
4
|
+
checks; the service answers with the probes it needs, the client runs them in the repo
|
|
5
|
+
(client.probes) and calls again with what each found, until the service has decided every check.
|
|
6
|
+
Only facts leave the machine, never a file's contents or a command's output. Every call goes to
|
|
7
|
+
the company's address with the machine's key and the plugin's version, over https (plain http
|
|
8
|
+
only to this machine itself, for the mock service and local work).
|
|
9
|
+
|
|
10
|
+
The plan lives on the service: it is made, written and read only through these calls, and the
|
|
11
|
+
service decides who may write or read which part of it. The hooks call this once they move to
|
|
12
|
+
the service (#69, #71, #74); until then the method runs on the machine (client/local.py), with
|
|
13
|
+
the plan in its own store there (LOCAL-PLAN), and the plugin is tested against the mock service
|
|
14
|
+
(client/mock/).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import tomllib
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from importlib import metadata
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from urllib.parse import urlencode, urlsplit, urlunsplit
|
|
26
|
+
|
|
27
|
+
from client import probes
|
|
28
|
+
|
|
29
|
+
PACKAGE = "ground-control-cli"
|
|
30
|
+
VERSION_HEADER = "Plugin-Version"
|
|
31
|
+
LOCAL = ("127.0.0.1", "localhost", "::1")
|
|
32
|
+
# Every name under .localhost is this machine (RFC 6761), whatever the resolver says.
|
|
33
|
+
LOOPBACK = "127.0.0.1"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _version() -> str:
|
|
37
|
+
"""The client's version: from its pyproject.toml in a clone, else as installed."""
|
|
38
|
+
pyproject = Path(__file__).resolve().parent / "pyproject.toml"
|
|
39
|
+
if pyproject.is_file():
|
|
40
|
+
return tomllib.loads(pyproject.read_text())["project"]["version"]
|
|
41
|
+
return metadata.version(PACKAGE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
VERSION = _version()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_local(host: str | None) -> bool:
|
|
48
|
+
"""Whether a host is this machine: localhost, a loopback address, or under .localhost.
|
|
49
|
+
|
|
50
|
+
Company: assumes none.
|
|
51
|
+
"""
|
|
52
|
+
host = (host or "").lower()
|
|
53
|
+
return host in LOCAL or host.endswith(".localhost")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fetch(
|
|
57
|
+
method: str,
|
|
58
|
+
url: str,
|
|
59
|
+
headers: dict | None = None,
|
|
60
|
+
data: bytes | None = None,
|
|
61
|
+
timeout: float = 30,
|
|
62
|
+
) -> tuple[int, dict, bytes]:
|
|
63
|
+
"""One https call (plain http only to this machine), its answer whatever its status.
|
|
64
|
+
|
|
65
|
+
A plain http call to a name under .localhost goes to this machine's loopback address,
|
|
66
|
+
carrying the name as its Host, so a company's local address works without the resolver.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
method: GET, POST or DELETE.
|
|
70
|
+
url: The address.
|
|
71
|
+
headers: The headers.
|
|
72
|
+
data: The body.
|
|
73
|
+
timeout: How long it may take, in seconds.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
(status, headers, body).
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValueError: If the address is not https, and not this machine.
|
|
80
|
+
ServiceError: With None when the address cannot be reached.
|
|
81
|
+
|
|
82
|
+
Company: the one at the address, if any.
|
|
83
|
+
"""
|
|
84
|
+
parts = urlsplit(url)
|
|
85
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and is_local(parts.hostname)):
|
|
86
|
+
raise ValueError(f"{url}: the service is reached over https only")
|
|
87
|
+
headers = dict(headers or {})
|
|
88
|
+
if parts.scheme == "http" and (parts.hostname or "").endswith(".localhost"):
|
|
89
|
+
headers["Host"] = parts.netloc
|
|
90
|
+
netloc = LOOPBACK + (f":{parts.port}" if parts.port else "")
|
|
91
|
+
url = urlunsplit(parts._replace(netloc=netloc))
|
|
92
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(request, timeout=timeout) as answer:
|
|
95
|
+
return answer.status, dict(answer.headers), answer.read()
|
|
96
|
+
except urllib.error.HTTPError as refused:
|
|
97
|
+
return refused.code, dict(refused.headers or {}), refused.read()
|
|
98
|
+
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
99
|
+
raise ServiceError(None, f"{parts.netloc} cannot be reached: {e}") from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ServiceError(Exception):
|
|
103
|
+
"""The service refused or could not be reached; `status` is its answer's, or None.
|
|
104
|
+
|
|
105
|
+
Company: assumes none.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, status: int | None, detail: str, refusal: str = ""):
|
|
109
|
+
"""Keep the status, what the service said, and which refusal it named, if any."""
|
|
110
|
+
super().__init__(detail)
|
|
111
|
+
self.status, self.detail, self.refusal = status, detail, refusal
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Service:
|
|
115
|
+
"""The service at one company's address, called with one machine's key.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
address: The company's address, such as https://acme.<domain>.
|
|
119
|
+
key: The machine's key.
|
|
120
|
+
timeout: How long one call may take, in seconds.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValueError: If the address is not https, and not this machine.
|
|
124
|
+
|
|
125
|
+
Company: the one at the address.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, address: str, key: str, timeout: float):
|
|
129
|
+
"""Refuse an address that is neither https nor this machine."""
|
|
130
|
+
parts = urlsplit(address)
|
|
131
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and is_local(parts.hostname)):
|
|
132
|
+
raise ValueError(f"{address}: the service is reached over https only")
|
|
133
|
+
self.address, self.key, self.timeout = address.rstrip("/"), key, timeout
|
|
134
|
+
|
|
135
|
+
def raw(self, method: str, path: str, body: dict | None = None) -> bytes:
|
|
136
|
+
"""One call to the service, its answer's body as it came.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
method: GET, POST or DELETE.
|
|
140
|
+
path: The route, with its query.
|
|
141
|
+
body: The JSON body, for a POST.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
The answer's body.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ServiceError: With the status, the service's detail and the Refusal header when
|
|
148
|
+
it refuses, or with None when it cannot be reached.
|
|
149
|
+
|
|
150
|
+
Company: the one at the address.
|
|
151
|
+
"""
|
|
152
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
153
|
+
headers = {
|
|
154
|
+
"Authorization": f"Bearer {self.key}",
|
|
155
|
+
VERSION_HEADER: VERSION,
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
}
|
|
158
|
+
status, answered, content = fetch(method, self.address + path, headers, data, self.timeout)
|
|
159
|
+
if status >= 400:
|
|
160
|
+
try:
|
|
161
|
+
detail = json.loads(content).get("detail", "")
|
|
162
|
+
except (json.JSONDecodeError, AttributeError, UnicodeDecodeError):
|
|
163
|
+
detail = ""
|
|
164
|
+
refusal = next((v for k, v in answered.items() if k.lower() == "refusal"), "")
|
|
165
|
+
raise ServiceError(status, str(detail) or f"refused, {status}", refusal)
|
|
166
|
+
return content
|
|
167
|
+
|
|
168
|
+
def call(self, method: str, path: str, body: dict | None = None) -> dict:
|
|
169
|
+
"""One call to the service.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
method: GET, POST or DELETE.
|
|
173
|
+
path: The route, with its query.
|
|
174
|
+
body: The JSON body, for a POST.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
The answer's JSON.
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ServiceError: With the status and the service's detail when it refuses, or with
|
|
181
|
+
None when it cannot be reached.
|
|
182
|
+
|
|
183
|
+
Company: the one at the address.
|
|
184
|
+
"""
|
|
185
|
+
return json.loads(self.raw(method, path, body) or b"null")
|
|
186
|
+
|
|
187
|
+
def run_checks(
|
|
188
|
+
self, repo: str, feature: str, step: str, moment: str, root, entering=None
|
|
189
|
+
) -> list[dict]:
|
|
190
|
+
"""Have a step's checks run on the service, running here the probes it asks for.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
194
|
+
feature: The feature, `<lane>/<name>`.
|
|
195
|
+
step: The step entered or exited.
|
|
196
|
+
moment: entry or exit.
|
|
197
|
+
root: The checkout the probes run in.
|
|
198
|
+
entering: The fields of the step_enter row being entered, for the entry checks.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Every check of the step, as the service decided it, in run order.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ServiceError: If the service refuses, cannot be reached, or asks again for a probe
|
|
205
|
+
it was given.
|
|
206
|
+
|
|
207
|
+
Company: the one at the address.
|
|
208
|
+
"""
|
|
209
|
+
body = {"repo": repo, "feature": feature, "step": step, "moment": moment}
|
|
210
|
+
body["entering"] = dict(entering or {})
|
|
211
|
+
found: list[dict] = []
|
|
212
|
+
ran: set[str] = set()
|
|
213
|
+
while True:
|
|
214
|
+
answer = self.call("POST", "/api/plugin/check", body | {"facts": found})
|
|
215
|
+
if not answer["need"]:
|
|
216
|
+
return answer["checks"]
|
|
217
|
+
for probe in answer["need"]:
|
|
218
|
+
key = json.dumps(probe, sort_keys=True)
|
|
219
|
+
if key in ran:
|
|
220
|
+
raise ServiceError(None, f"the service asked again for {probe['name']}")
|
|
221
|
+
ran.add(key)
|
|
222
|
+
fact = probes.run(probe["name"], probe["params"], root)
|
|
223
|
+
found.append(probe | {"fact": fact})
|
|
224
|
+
|
|
225
|
+
def create_plan(self, repo: str, feature: str, role: str, issue: str = "") -> int:
|
|
226
|
+
"""Have the service make a feature's plan, as its first version.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
230
|
+
feature: The feature, `<lane>/<name>`.
|
|
231
|
+
role: Who makes it.
|
|
232
|
+
issue: The request's address, or "".
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The version kept.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ServiceError: If the service refuses or cannot be reached.
|
|
239
|
+
|
|
240
|
+
Company: the one at the address.
|
|
241
|
+
"""
|
|
242
|
+
body = {"repo": repo, "feature": feature, "issue": issue, "role": role}
|
|
243
|
+
return self.call("POST", "/api/plugin/plan", body)["version"]
|
|
244
|
+
|
|
245
|
+
def write_plan(
|
|
246
|
+
self,
|
|
247
|
+
repo: str,
|
|
248
|
+
feature: str,
|
|
249
|
+
role: str,
|
|
250
|
+
section: str,
|
|
251
|
+
operation: str,
|
|
252
|
+
row: str = "",
|
|
253
|
+
cells: dict | None = None,
|
|
254
|
+
text: str = "",
|
|
255
|
+
subsection: str = "",
|
|
256
|
+
) -> tuple[str, int]:
|
|
257
|
+
"""Have the service apply one write to a feature's plan.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
261
|
+
feature: The feature, `<lane>/<name>`.
|
|
262
|
+
role: Who writes.
|
|
263
|
+
section: The plan's section.
|
|
264
|
+
operation: set, add, update or remove.
|
|
265
|
+
row: The row, for update and remove.
|
|
266
|
+
cells: The row's cells, for add and update.
|
|
267
|
+
text: The text, for set.
|
|
268
|
+
subsection: `Server side` of Flow, with set.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
What was done, and the version kept.
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
ServiceError: If the service refuses the write or cannot be reached.
|
|
275
|
+
|
|
276
|
+
Company: the one at the address.
|
|
277
|
+
"""
|
|
278
|
+
body = {
|
|
279
|
+
"repo": repo,
|
|
280
|
+
"feature": feature,
|
|
281
|
+
"role": role,
|
|
282
|
+
"section": section,
|
|
283
|
+
"operation": operation,
|
|
284
|
+
"row": row,
|
|
285
|
+
"cells": {k: str(v) for k, v in (cells or {}).items()},
|
|
286
|
+
"text": text,
|
|
287
|
+
"subsection": subsection,
|
|
288
|
+
}
|
|
289
|
+
answer = self.call("POST", "/api/plugin/plan/write", body)
|
|
290
|
+
return answer["done"], answer["version"]
|
|
291
|
+
|
|
292
|
+
def read_plan(
|
|
293
|
+
self, repo: str, feature: str, role: str, sections=(), whole: bool = False
|
|
294
|
+
) -> tuple[int, str]:
|
|
295
|
+
"""Read sections of a feature's latest plan from the service.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
299
|
+
feature: The feature, `<lane>/<name>`.
|
|
300
|
+
role: Who reads.
|
|
301
|
+
sections: The sections asked for.
|
|
302
|
+
whole: Whether the whole plan is asked for.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The version read, and the sections as Markdown.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
ServiceError: If the service refuses the read or cannot be reached.
|
|
309
|
+
|
|
310
|
+
Company: the one at the address.
|
|
311
|
+
"""
|
|
312
|
+
query = {"repo": repo, "feature": feature, "role": role, "section": list(sections)}
|
|
313
|
+
if whole:
|
|
314
|
+
query["whole"] = "true"
|
|
315
|
+
answer = self.call("GET", "/api/plugin/plan?" + urlencode(query, doseq=True))
|
|
316
|
+
return answer["version"], answer["text"]
|
|
317
|
+
|
|
318
|
+
def request(self, repo: str, request: str) -> dict:
|
|
319
|
+
"""What `method start` needs of a request, read by the service from the company's GitHub.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
323
|
+
request: The request's number in the repo, or its address.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
{url, name, lane, priority}: never the request's words.
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ServiceError: 409 when the company has not connected GitHub (the machine reads it
|
|
330
|
+
itself) or the request cannot be read; or if the service cannot be reached.
|
|
331
|
+
|
|
332
|
+
Company: the one at the address.
|
|
333
|
+
"""
|
|
334
|
+
return self.call(
|
|
335
|
+
"GET", "/api/plugin/request?" + urlencode({"repo": repo, "request": request})
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def acceptance_report(self, repo: str, feature: str) -> dict:
|
|
339
|
+
"""A feature's last acceptance run, as the service builds it from the run's facts.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
repo: The repo, as its host writes it, such as owner/repo.
|
|
343
|
+
feature: The feature, `<lane>/<name>`.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
{commit, proven, total, promises: [{id, proven, rows: [{id, test, state}]}]}.
|
|
347
|
+
|
|
348
|
+
Raises:
|
|
349
|
+
ServiceError: 409 when the feature has no run yet; or if the service refuses or
|
|
350
|
+
cannot be reached.
|
|
351
|
+
|
|
352
|
+
Company: the one at the address.
|
|
353
|
+
"""
|
|
354
|
+
query = urlencode({"repo": repo, "feature": feature})
|
|
355
|
+
return self.call("GET", "/api/plugin/acceptance-report?" + query)
|
|
356
|
+
|
|
357
|
+
def who_am_i(self) -> dict:
|
|
358
|
+
"""Who the key belongs to: {machine, person, company}, as the service knows them.
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
ServiceError: If the service refuses the key or cannot be reached.
|
|
362
|
+
|
|
363
|
+
Company: the one at the address.
|
|
364
|
+
"""
|
|
365
|
+
return self.call("GET", "/api/machine/me")
|
|
366
|
+
|
|
367
|
+
def managed(self) -> list[dict]:
|
|
368
|
+
"""The repos the method guards in the company, each with every identifier a clone may know.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
[{github_id, name, previous_names, https_url, ssh_url}].
|
|
372
|
+
|
|
373
|
+
Raises:
|
|
374
|
+
ServiceError: If the service refuses or cannot be reached.
|
|
375
|
+
|
|
376
|
+
Company: the one at the address.
|
|
377
|
+
"""
|
|
378
|
+
return self.call("GET", "/api/plugin/repos/managed")
|
|
379
|
+
|
|
380
|
+
def store(self) -> bytes:
|
|
381
|
+
"""The plugin's store, a zip, as the service serves it at its own version.
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
ServiceError: If the service refuses or cannot be reached.
|
|
385
|
+
|
|
386
|
+
Company: the one at the address.
|
|
387
|
+
"""
|
|
388
|
+
return self.raw("GET", "/api/plugin/store")
|
|
389
|
+
|
|
390
|
+
def disconnect(self) -> None:
|
|
391
|
+
"""Disconnect this machine on the service: its key is refused from now on.
|
|
392
|
+
|
|
393
|
+
Raises:
|
|
394
|
+
ServiceError: If the service refuses or cannot be reached.
|
|
395
|
+
|
|
396
|
+
Company: the one at the address.
|
|
397
|
+
"""
|
|
398
|
+
self.raw("DELETE", "/api/machine/me")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""What depends on the machine's operating system, behind one interface: `System`.
|
|
2
|
+
|
|
3
|
+
The keychain that keeps each company's key, the machine's own facts (its name, a hash of its
|
|
4
|
+
hardware's id, its model and OS), and opening the browser. A Mac only, for now: another system
|
|
5
|
+
is one more module here, picked by `current`, and nothing else changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
# The keychain's service name for the keys: one item per company, its account the address.
|
|
15
|
+
KEYCHAIN_SERVICE = "groundcontrol"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Unsupported(Exception):
|
|
19
|
+
"""This operating system, or this machine, cannot run the client; the message says why."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class System(Protocol):
|
|
23
|
+
"""The operating system's side of the client.
|
|
24
|
+
|
|
25
|
+
Company: assumes none.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
|
|
30
|
+
def keychain_ready(self) -> str:
|
|
31
|
+
"""Why the keychain cannot keep a key, or "" when it can."""
|
|
32
|
+
|
|
33
|
+
def get_key(self, address: str) -> str | None:
|
|
34
|
+
"""The company's key, from the keychain, or None."""
|
|
35
|
+
|
|
36
|
+
def set_key(self, address: str, key: str) -> None:
|
|
37
|
+
"""Keep the company's key in the keychain, replacing any."""
|
|
38
|
+
|
|
39
|
+
def delete_key(self, address: str) -> bool:
|
|
40
|
+
"""Delete the company's key from the keychain; whether there was one."""
|
|
41
|
+
|
|
42
|
+
def machine_name(self) -> str:
|
|
43
|
+
"""The machine's own name, such as Dana's MacBook Pro."""
|
|
44
|
+
|
|
45
|
+
def hardware_id(self) -> str:
|
|
46
|
+
"""A hash of the operating system's id for the hardware: the same machine, the same hash."""
|
|
47
|
+
|
|
48
|
+
def model(self) -> str:
|
|
49
|
+
"""The machine's model, such as MacBookPro18,3."""
|
|
50
|
+
|
|
51
|
+
def os(self) -> str:
|
|
52
|
+
"""The operating system and its version, such as macOS 15.6."""
|
|
53
|
+
|
|
54
|
+
def open_browser(self, url: str) -> bool:
|
|
55
|
+
"""Open a page in the person's browser; whether it opened."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def current() -> System:
|
|
59
|
+
"""The running operating system's System.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The System.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
Unsupported: On any system but a Mac.
|
|
66
|
+
|
|
67
|
+
Company: assumes none.
|
|
68
|
+
"""
|
|
69
|
+
if sys.platform == "darwin":
|
|
70
|
+
from client.system.macos import MacOS
|
|
71
|
+
|
|
72
|
+
return MacOS()
|
|
73
|
+
raise Unsupported(f"the client runs on a Mac only, for now; this is {platform.system()}")
|
client/system/macos.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A Mac's side of the client: the login keychain through `security`, and the machine's facts.
|
|
2
|
+
|
|
3
|
+
A key never touches a file, and never a command's arguments: it reaches `security` on its
|
|
4
|
+
standard input (`security -i`), where no other process sees it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from client.system import KEYCHAIN_SERVICE
|
|
14
|
+
|
|
15
|
+
NO_KEYCHAIN = "this Mac has no keychain to keep the key in: open Keychain Access and make one"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess:
|
|
19
|
+
"""Run a command of the system, its output as text; never raises for its exit code."""
|
|
20
|
+
try:
|
|
21
|
+
return subprocess.run(args, input=stdin, capture_output=True, text=True, timeout=30)
|
|
22
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
23
|
+
return subprocess.CompletedProcess(args, 1, "", str(e))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _quoted(value: str) -> str:
|
|
27
|
+
"""A value as `security -i` reads one argument."""
|
|
28
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MacOS:
|
|
32
|
+
"""The Mac's System.
|
|
33
|
+
|
|
34
|
+
Company: assumes none.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name = "macos"
|
|
38
|
+
|
|
39
|
+
def keychain_ready(self) -> str:
|
|
40
|
+
"""Why the keychain cannot keep a key, or "" when it can."""
|
|
41
|
+
found = _run("security", "default-keychain")
|
|
42
|
+
return "" if found.returncode == 0 and found.stdout.strip() else NO_KEYCHAIN
|
|
43
|
+
|
|
44
|
+
def get_key(self, address: str) -> str | None:
|
|
45
|
+
"""The company's key, from the login keychain, or None."""
|
|
46
|
+
found = _run(
|
|
47
|
+
"security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", address, "-w"
|
|
48
|
+
)
|
|
49
|
+
return found.stdout.strip() or None if found.returncode == 0 else None
|
|
50
|
+
|
|
51
|
+
def set_key(self, address: str, key: str) -> None:
|
|
52
|
+
"""Keep the company's key in the login keychain, replacing any.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
OSError: If the keychain refuses it.
|
|
56
|
+
"""
|
|
57
|
+
command = " ".join(
|
|
58
|
+
["add-generic-password", "-U", "-s", _quoted(KEYCHAIN_SERVICE)]
|
|
59
|
+
+ ["-a", _quoted(address), "-l", _quoted(f"{KEYCHAIN_SERVICE} {address}")]
|
|
60
|
+
+ ["-w", _quoted(key)]
|
|
61
|
+
)
|
|
62
|
+
done = _run("security", "-i", stdin=command + "\n")
|
|
63
|
+
if done.returncode != 0 or self.get_key(address) != key:
|
|
64
|
+
raise OSError(f"the keychain did not keep the key: {done.stderr.strip()}")
|
|
65
|
+
|
|
66
|
+
def delete_key(self, address: str) -> bool:
|
|
67
|
+
"""Delete the company's key from the login keychain; whether there was one."""
|
|
68
|
+
done = _run("security", "delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", address)
|
|
69
|
+
return done.returncode == 0
|
|
70
|
+
|
|
71
|
+
def machine_name(self) -> str:
|
|
72
|
+
"""The Mac's name, as Sharing shows it."""
|
|
73
|
+
return _run("scutil", "--get", "ComputerName").stdout.strip()
|
|
74
|
+
|
|
75
|
+
def hardware_id(self) -> str:
|
|
76
|
+
"""The sha256 of the Mac's IOPlatformUUID; never the id itself."""
|
|
77
|
+
found = _run("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").stdout
|
|
78
|
+
match = re.search(r'"IOPlatformUUID"\s*=\s*"([^"]+)"', found)
|
|
79
|
+
return hashlib.sha256(match[1].encode()).hexdigest() if match else ""
|
|
80
|
+
|
|
81
|
+
def model(self) -> str:
|
|
82
|
+
"""The Mac's model identifier."""
|
|
83
|
+
return _run("sysctl", "-n", "hw.model").stdout.strip()
|
|
84
|
+
|
|
85
|
+
def os(self) -> str:
|
|
86
|
+
"""MacOS and its version."""
|
|
87
|
+
version = _run("sw_vers", "-productVersion").stdout.strip()
|
|
88
|
+
return f"macOS {version}".strip()
|
|
89
|
+
|
|
90
|
+
def open_browser(self, url: str) -> bool:
|
|
91
|
+
"""Open the page in the default browser."""
|
|
92
|
+
return _run("open", url).returncode == 0
|
client/update.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The client keeping itself current, at the start of a session and never during one.
|
|
2
|
+
|
|
3
|
+
The service says the oldest client it still answers and the latest one (`GET /api/client`).
|
|
4
|
+
Below the oldest, the client updates before anything else; below the latest, it updates
|
|
5
|
+
without asking. It updates through uv, which installed it; a client run from a clone of the
|
|
6
|
+
product's repo is that clone's, and is never updated here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
|
|
17
|
+
from client.service import PACKAGE, VERSION, ServiceError, fetch
|
|
18
|
+
|
|
19
|
+
TIMEOUT = 300
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def numbers(version: str) -> tuple[int, int, int] | None:
|
|
23
|
+
"""A version's three numbers, or None.
|
|
24
|
+
|
|
25
|
+
Company: assumes none.
|
|
26
|
+
"""
|
|
27
|
+
found = re.match(r"^\s*(\d+)\.(\d+)\.(\d+)", version or "")
|
|
28
|
+
return (int(found[1]), int(found[2]), int(found[3])) if found else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def held_to(door: str) -> dict:
|
|
32
|
+
"""What the service holds the client to: {minimum, latest, plugin}, asked at its door.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
door: The door's origin.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The answer.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ServiceError: If the service cannot be reached or does not answer.
|
|
42
|
+
|
|
43
|
+
Company: assumes none.
|
|
44
|
+
"""
|
|
45
|
+
status, _, body = fetch("GET", door.rstrip("/") + "/api/client")
|
|
46
|
+
if status != 200:
|
|
47
|
+
raise ServiceError(status, f"{door} did not say which versions it needs")
|
|
48
|
+
return json.loads(body)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def installed_by_uv() -> bool:
|
|
52
|
+
"""Whether this client was installed as a uv tool, so it can update itself.
|
|
53
|
+
|
|
54
|
+
Company: assumes none.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
metadata.version(PACKAGE)
|
|
58
|
+
except metadata.PackageNotFoundError:
|
|
59
|
+
return False
|
|
60
|
+
return shutil.which("uv") is not None and "/uv/tools/" in __file__.replace("\\", "/")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def needed(versions: dict, current: str = VERSION) -> str:
|
|
64
|
+
"""Whether the client must update: "required", "available", or "".
|
|
65
|
+
|
|
66
|
+
Company: assumes none.
|
|
67
|
+
"""
|
|
68
|
+
here = numbers(current)
|
|
69
|
+
if here is None or here < (numbers(versions["minimum"]) or (0, 0, 0)):
|
|
70
|
+
return "required"
|
|
71
|
+
if here < (numbers(versions["latest"]) or (0, 0, 0)):
|
|
72
|
+
return "available"
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def update() -> str:
|
|
77
|
+
"""Update the client to the latest version on the registry.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The version it is now.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
RuntimeError: If it cannot update itself.
|
|
84
|
+
|
|
85
|
+
Company: assumes none.
|
|
86
|
+
"""
|
|
87
|
+
if not installed_by_uv():
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"this client was not installed by the install line; it cannot update itself"
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
done = subprocess.run(
|
|
93
|
+
["uv", "tool", "upgrade", "--quiet", PACKAGE],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=TIMEOUT,
|
|
97
|
+
stdin=subprocess.DEVNULL,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
100
|
+
raise RuntimeError(f"uv could not update the client: {e}") from None
|
|
101
|
+
if done.returncode != 0:
|
|
102
|
+
said = (done.stderr or done.stdout).strip().splitlines()
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"uv could not update the client: {said[-1] if said else done.returncode}"
|
|
105
|
+
)
|
|
106
|
+
found = subprocess.run(
|
|
107
|
+
["uv", "tool", "list"], capture_output=True, text=True, timeout=TIMEOUT
|
|
108
|
+
).stdout
|
|
109
|
+
match = re.search(rf"^{re.escape(PACKAGE)} v(\S+)", found, re.MULTILINE)
|
|
110
|
+
return match[1] if match else "the latest"
|