ground-control-cli 0.5.1__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/PKG-INFO +1 -1
  2. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/__init__.py +1 -3
  3. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/pyproject.toml +1 -1
  4. ground_control_cli-0.5.1/probes/deploy.py +0 -261
  5. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/.gitignore +0 -0
  6. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/__init__.py +0 -0
  7. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/actions.py +0 -0
  8. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/claude.py +0 -0
  9. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/cli.py +0 -0
  10. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/connect.py +0 -0
  11. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/hooks.py +0 -0
  12. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/machine.py +0 -0
  13. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/code/__init__.py +0 -0
  14. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/code/bash.py +0 -0
  15. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/code/javascript.js +0 -0
  16. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/code/javascript.py +0 -0
  17. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/code/python.py +0 -0
  18. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/errors.py +0 -0
  19. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/gh.py +0 -0
  20. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/git.py +0 -0
  21. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/health.py +0 -0
  22. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/queue.py +0 -0
  23. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/request.py +0 -0
  24. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/settings.py +0 -0
  25. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/tests.py +0 -0
  26. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/probes/workspace.py +0 -0
  27. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/queue.py +0 -0
  28. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/repos.py +0 -0
  29. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/service.py +0 -0
  30. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/system/__init__.py +0 -0
  31. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/system/macos.py +0 -0
  32. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/tools.py +0 -0
  33. {ground_control_cli-0.5.1 → ground_control_cli-0.5.2}/update.py +0 -0
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ground-control-cli
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: What runs on a developer's machine: the command line and the agent's plugin.
5
5
  Requires-Python: >=3.12
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from collections.abc import Callable
15
15
 
16
- from . import code, deploy, gh, git, health, queue, request, tests, workspace
16
+ from . import code, gh, git, health, queue, request, tests, workspace
17
17
 
18
18
  # Every probe the client runs: its name -> the function of (params, root) that runs it.
19
19
  RUNS: dict[str, Callable[[dict, object], dict]] = {
@@ -41,8 +41,6 @@ RUNS: dict[str, Callable[[dict, object], dict]] = {
41
41
  "git.remote_branch": git.remote_branch,
42
42
  "request.read": request.read,
43
43
  "workspace.changes": workspace.changes,
44
- "deploy.commit": deploy.commit,
45
- "deploy.discover": deploy.discover,
46
44
  "queue.waiting": queue.waiting,
47
45
  }
48
46
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ground-control-cli"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "What runs on a developer's machine: the command line and the agent's plugin."
5
5
  requires-python = ">=3.12"
6
6
  dependencies = []
@@ -1,261 +0,0 @@
1
- """The deploy probes: which commit production runs, and where production says it.
2
-
3
- Production is asked from the developer's machine, never from the service. deploy.commit asks the
4
- health address once, or every poll until production runs the commit waited for or a descendant
5
- of it; deploy.discover finds the health address in the repo. A fact is a commit, an address, a
6
- status, a field's name; never what an answer said beyond the commit.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import re
13
- import subprocess
14
- import time
15
- import urllib.error
16
- import urllib.request
17
- from pathlib import Path
18
- from urllib.parse import urlsplit
19
-
20
- from . import errors
21
-
22
- MIN_PREFIX = 7
23
- HEALTH_PATHS = ("/healthz", "/health", "/healthcheck", "/api/health", "/status", "/ready", "/livez")
24
- ENV_EXAMPLES = (".env.example", ".env.sample", ".env.template", "example.env", "sample.env")
25
- PRODUCTION_KEY_RE = re.compile(
26
- r"""^\s*(?:export\s+)?([A-Z0-9_]*PROD[A-Z0-9_]*_(?:URL|DOMAIN|HOST))\s*=\s*["']?([^"'\s#]+)"""
27
- )
28
- TOML_SECTION_RE = re.compile(r"^\s*\[([^\]]+)\]\s*$")
29
- TOML_HEALTH_RE = re.compile(r"""^\s*healthcheckPath\s*=\s*["']([^"']+)["']""")
30
- HEX_RE = re.compile(r"^[0-9a-fA-F]+$")
31
- # The extensions a code reader reads, and the shebang of a script it reads: where discovery looks
32
- # for a health path as a string literal.
33
- SOURCE = (".py", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".html", ".sh")
34
- SHEBANG_RE = re.compile(r"#!\s*(?:\S*/)?(?:env\s+(?:-\S+\s+)*)?(?:\S*/)?(?:bash|sh)(?:\s|$)")
35
-
36
-
37
- def _git(root, *args) -> subprocess.CompletedProcess:
38
- return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True)
39
-
40
-
41
- def _resolve(root, ref: str) -> str:
42
- out = _git(root, "rev-parse", "--verify", "-q", f"{ref}^{{commit}}")
43
- return out.stdout.strip() if out.returncode == 0 else ""
44
-
45
-
46
- def _commit_of(root, prefix: str) -> str:
47
- """The full commit a prefix names, fetching origin once when it is not known yet; or ""."""
48
- if not (len(prefix) >= MIN_PREFIX and HEX_RE.match(prefix)):
49
- return ""
50
- found = _resolve(root, prefix)
51
- if not found:
52
- _git(root, "fetch", "-q", "origin")
53
- found = _resolve(root, prefix)
54
- return found
55
-
56
-
57
- def _covers(root, returned: str, release: str) -> bool:
58
- return bool(returned and release) and (
59
- _git(root, "merge-base", "--is-ancestor", release, returned).returncode == 0
60
- )
61
-
62
-
63
- def _fetch(url: str, limit: float) -> tuple:
64
- """(the reply's JSON, None, status) or (None, why, status)."""
65
- try:
66
- with urllib.request.urlopen(url, timeout=limit) as reply:
67
- body, status = reply.read(), reply.status
68
- except urllib.error.HTTPError as e:
69
- return None, "not_2xx", e.code
70
- except (urllib.error.URLError, OSError, ValueError):
71
- return None, "no_reply", None
72
- try:
73
- return json.loads(body.decode("utf-8", errors="replace")), None, status
74
- except json.JSONDecodeError:
75
- return None, "not_json", status
76
-
77
-
78
- def fields(reply) -> dict:
79
- """The top-level fields of a JSON object, and those one level down as `outer.inner`."""
80
- out = {}
81
- if not isinstance(reply, dict):
82
- return out
83
- for key, value in reply.items():
84
- out[key] = value
85
- if isinstance(value, dict):
86
- for inner, deeper in value.items():
87
- out[f"{key}.{inner}"] = deeper
88
- return out
89
-
90
-
91
- def commit(params: dict, root) -> dict:
92
- """deploy.commit: the commit production runs, waiting for one when asked to.
93
-
94
- Args:
95
- params: {"url", "commit_field", "until", "timeout", "poll", "limit"}.
96
- root: The repo, where commits are resolved.
97
-
98
- Returns:
99
- The DeployFacts fields.
100
-
101
- Company: assumes none.
102
- """
103
- until, end = params.get("until") or "", time.monotonic() + float(params.get("timeout") or 0)
104
- seen = ""
105
- while True:
106
- reply, _, _ = _fetch(params["url"], float(params.get("limit") or 5))
107
- value = fields(reply).get(params["commit_field"]) if reply is not None else None
108
- seen = _commit_of(root, value.lower()) if isinstance(value, str) else ""
109
- if not until or _covers(root, seen, until):
110
- break
111
- left = end - time.monotonic()
112
- if left <= 0:
113
- break
114
- time.sleep(min(float(params.get("poll") or 30), left))
115
- if not seen:
116
- return {"error": errors.NOT_FOUND}
117
- return {"commit": seen, "covers": bool(until) and _covers(root, seen, until)}
118
-
119
-
120
- # --- discover ----------------------------------------------------------------------------------
121
-
122
-
123
- def _read(root, name: str) -> str:
124
- try:
125
- return (Path(root) / name).read_text(encoding="utf-8", errors="replace")
126
- except OSError:
127
- return ""
128
-
129
-
130
- def _is_source(root, name: str) -> bool:
131
- if Path(name).suffix.lower() in SOURCE:
132
- return True
133
- first = _read(root, name).split("\n", 1)[0]
134
- return bool(SHEBANG_RE.match(first.strip()))
135
-
136
-
137
- def _base(value: str) -> str:
138
- parts = urlsplit(value if "://" in value else f"https://{value}")
139
- return f"{parts.scheme}://{parts.netloc}" if parts.netloc else ""
140
-
141
-
142
- def _domain(root, files: list) -> str:
143
- for name in files:
144
- if Path(name).name == "vercel.json":
145
- try:
146
- alias = json.loads(_read(root, name)).get("alias")
147
- except (json.JSONDecodeError, AttributeError):
148
- alias = None
149
- alias = alias[0] if isinstance(alias, list) and alias else alias
150
- if isinstance(alias, str) and _base(alias.strip()):
151
- return _base(alias.strip())
152
- for name in files:
153
- if Path(name).name == "CNAME":
154
- host = _read(root, name).strip().splitlines()
155
- if host and _base(host[0].strip()):
156
- return _base(host[0].strip())
157
- for name in files:
158
- if Path(name).name in ENV_EXAMPLES or Path(name).name.endswith(".env.example"):
159
- for line in _read(root, name).splitlines():
160
- m = PRODUCTION_KEY_RE.match(line)
161
- if m and _base(m.group(2)):
162
- return _base(m.group(2))
163
- return ""
164
-
165
-
166
- def _host_path(root, files: list) -> str:
167
- for name in files:
168
- base = Path(name).name
169
- if base == "railway.json":
170
- try:
171
- deploy = json.loads(_read(root, name)).get("deploy") or {}
172
- path = deploy.get("healthcheckPath", "")
173
- except (json.JSONDecodeError, AttributeError):
174
- path = ""
175
- if isinstance(path, str) and path.startswith("/"):
176
- return path
177
- elif base == "railway.toml":
178
- section = ""
179
- for line in _read(root, name).splitlines():
180
- m = TOML_SECTION_RE.match(line)
181
- if m:
182
- section = m.group(1).strip()
183
- continue
184
- m = TOML_HEALTH_RE.match(line)
185
- if m and section == "deploy" and m.group(1).startswith("/"):
186
- return m.group(1)
187
- return ""
188
-
189
-
190
- def _paths(root, files: list) -> list:
191
- """The host settings' health path, then each usual one written as a literal in the source."""
192
- out = [p for p in (_host_path(root, files),) if p]
193
- texts = [_read(root, n) for n in files if (Path(root) / n).is_file() and _is_source(root, n)]
194
- for path in HEALTH_PATHS:
195
- literal = re.compile(r"""(["'`])""" + re.escape(path) + r"\1")
196
- if path not in out and any(literal.search(t) for t in texts):
197
- out.append(path)
198
- return out
199
-
200
-
201
- def discover(params: dict, root) -> dict:
202
- """deploy.discover: the first health address whose JSON reply names a commit on origin/main.
203
-
204
- Args:
205
- params: {"domain", "commit_field"}.
206
- root: The repo.
207
-
208
- Returns:
209
- The DiscoverFacts fields.
210
-
211
- Company: assumes none.
212
- """
213
- out = _git(root, "ls-files", "-z")
214
- if out.returncode != 0:
215
- return {"error": errors.failed(out.stderr)}
216
- files = [f for f in out.stdout.split("\0") if f]
217
- base = _base(params["domain"]) if params.get("domain") else _domain(root, files)
218
- if not base:
219
- return {"no_domain": True}
220
- paths = _paths(root, files)
221
- if not paths:
222
- return {"no_path": True}
223
- _git(root, "fetch", "-q", "origin", "main")
224
- listed = _git(root, "rev-list", "origin/main")
225
- main = listed.stdout.split() if listed.returncode == 0 else []
226
- wanted, tried = params.get("commit_field") or "", []
227
- for path in paths:
228
- url = base + path
229
- reply, why, status = _fetch(url, 5)
230
- if reply is None:
231
- tried.append({"url": url, "reason": why, "status": status})
232
- continue
233
- found = {
234
- name: value
235
- for name, value in fields(reply).items()
236
- if isinstance(value, str)
237
- and len(value) >= MIN_PREFIX
238
- and HEX_RE.match(value)
239
- and any(c.startswith(value.lower()) for c in main)
240
- }
241
- if not found:
242
- names = sorted(fields(reply))
243
- tried.append(
244
- {"url": url, "reason": "no_commit_field", "status": status, "fields": names}
245
- )
246
- continue
247
- if wanted:
248
- if wanted in found:
249
- return {"url": url, "commit_field": wanted, "value": found[wanted], "tried": tried}
250
- tried.append(
251
- {"url": url, "reason": "not_that_field", "status": status, "fields": sorted(found)}
252
- )
253
- continue
254
- if len(found) > 1:
255
- tried.append(
256
- {"url": url, "reason": "several_fields", "status": status, "fields": sorted(found)}
257
- )
258
- return {"tried": tried}
259
- [(name, value)] = found.items()
260
- return {"url": url, "commit_field": name, "value": value, "tried": tried}
261
- return {"tried": tried}