code-coordinator 0.5.46__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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/tui_release.py ADDED
@@ -0,0 +1,271 @@
1
+ """Resolve, download, and atomically install the `coord-tui` binary from
2
+ this project's own GitHub Release channel (#1240, PKG-4).
3
+
4
+ PKG-3 (#1239, ``.github/workflows/release-tui.yml``) already builds
5
+ ``coord-tui`` for linux/macOS/Windows on every ``vX.Y.Z`` tag push and
6
+ attaches ``coord-tui-<target>`` assets to the same GitHub Release the Python
7
+ wheel publishes to, with ``tui/Cargo.toml``'s version stamped from that same
8
+ tag so ``coord-tui --version`` and ``coord --version`` agree on a release.
9
+ This module is the client half: given the coordinator's own version (the
10
+ single source PKG-2 established — ``coord.__version__``), find the matching
11
+ release, detect which of PKG-3's build-matrix targets this host needs, and
12
+ install the binary without a human ever visiting the Releases page by hand.
13
+
14
+ Kept framework-agnostic (no ``click``) so :mod:`coord.commands.tui`'s CLI
15
+ wiring is a thin layer over functions a plain unit test can call directly.
16
+
17
+ #2102: a `tui/`-only release now publishes no PyPI wheel — PyPI's simple
18
+ index is deliberately left pointing at the older version, since the wheel a
19
+ `tui/`-only range would build is a no-op. That is safe for this whole module
20
+ precisely because nothing here ever asks PyPI anything: every function below
21
+ resolves the release to install from the GitHub Releases API alone (`--repo`/
22
+ `--api-base`, defaulting to this project's own release channel), keyed on the
23
+ tag it is given (or, by default, ``coord.__version__`` — this coordinator's
24
+ own installed version, resolved from :mod:`coord`, not from PyPI). Installing
25
+ an explicit ``coord tui update --version X.Y.Z`` therefore works identically
26
+ whether or not X.Y.Z's release carries a wheel.
27
+
28
+ Two invariants drive the design:
29
+
30
+ * **Atomic install.** The download always lands in a temp file *in the same
31
+ directory* as the destination (so the final ``os.replace`` is a same
32
+ -filesystem rename, atomic on POSIX) and is only ``chmod +x``'d and moved
33
+ into place after it fully downloads (and, when a checksum asset is
34
+ published, verifies). A concurrent ``coord-tui`` process, or an
35
+ interrupted download, can only ever observe the old binary or the new
36
+ one — never a partial file at the destination path.
37
+
38
+ * **Never clobber a dev build silently.** ``tui/Cargo.toml``'s committed
39
+ ``[package] version`` is the ``0.1.0`` placeholder — see
40
+ ``tui/src/main.rs``'s ``version_string()`` docstring — and only
41
+ ``release-tui.yml``'s CI build stamps a real ``vX.Y.Z`` over it, as a
42
+ build-time edit that is never committed back. A binary built locally with
43
+ a bare ``cargo build`` therefore *always* reports exactly ``coord-tui
44
+ 0.1.0``, forever, regardless of when it was built. That sentinel is used
45
+ as a free, config-free signal that whatever's currently installed at the
46
+ destination is a developer's own local build, not a stale release —
47
+ :func:`is_dev_build` checks for it, and ``coord tui update`` refuses to
48
+ overwrite it without ``--force``.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import hashlib
54
+ import os
55
+ import platform
56
+ import subprocess
57
+ import tempfile
58
+ from dataclasses import dataclass
59
+ from pathlib import Path
60
+
61
+ #: The GitHub ``owner/repo`` this coordinator's own releases live on
62
+ #: (matches pyproject.toml's ``[project.urls] Repository``).
63
+ DEFAULT_REPO = "JDonaghy/claude-coordinator"
64
+
65
+ #: GitHub's REST API root. Overridable (``coord tui update --api-base``) so
66
+ #: tests can point this at a local stub server instead of the real network.
67
+ DEFAULT_API_BASE = "https://api.github.com"
68
+
69
+ #: Where `coord tui update` installs by default — same documented default
70
+ #: as `coord.config.HealthConfig.tui_binary_path` / the README's manual
71
+ #: `cp target/debug/coord-tui ~/.local/bin/coord-tui` instructions.
72
+ DEFAULT_INSTALL_PATH = "~/.local/bin/coord-tui"
73
+
74
+ #: See the module docstring's "never clobber a dev build" invariant:
75
+ #: `tui/Cargo.toml`'s committed `[package] version` field, which a plain
76
+ #: `cargo build` always reports verbatim because only release-tui.yml's CI
77
+ #: stamps a real version over it (and never commits that stamp back).
78
+ DEV_BUILD_SENTINEL_VERSION = "0.1.0"
79
+
80
+ #: release-tui.yml's build matrix, keyed by (system, machine) as reported by
81
+ #: Python's own `platform` module — mirrors that workflow's `target_name`
82
+ #: values exactly so the asset name this resolves to always matches what
83
+ #: the release actually published.
84
+ _TARGETS: dict[tuple[str, str], str] = {
85
+ ("linux", "x86_64"): "x86_64-linux",
86
+ ("linux", "amd64"): "x86_64-linux",
87
+ ("darwin", "x86_64"): "x86_64-macos",
88
+ ("darwin", "amd64"): "x86_64-macos",
89
+ ("darwin", "arm64"): "aarch64-macos",
90
+ ("darwin", "aarch64"): "aarch64-macos",
91
+ ("windows", "x86_64"): "x86_64-windows",
92
+ ("windows", "amd64"): "x86_64-windows",
93
+ }
94
+
95
+
96
+ class UnsupportedPlatformError(RuntimeError):
97
+ """No release-tui.yml build matrix target covers this host."""
98
+
99
+
100
+ class ReleaseAssetNotFoundError(RuntimeError):
101
+ """The release exists but doesn't carry the asset this host needs."""
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class ReleaseAsset:
106
+ name: str
107
+ download_url: str
108
+ size: int | None = None
109
+
110
+
111
+ def detect_target(system: str | None = None, machine: str | None = None) -> str:
112
+ """The release-tui.yml build-matrix target name for this host, e.g.
113
+ ``"x86_64-linux"``. *system*/*machine* default to
114
+ ``platform.system()``/``platform.machine()`` — parameters exist purely
115
+ so tests can drive every branch without mocking the platform module."""
116
+ system = (system if system is not None else platform.system()).lower()
117
+ machine = (machine if machine is not None else platform.machine()).lower()
118
+ target = _TARGETS.get((system, machine))
119
+ if target is None:
120
+ raise UnsupportedPlatformError(
121
+ f"no coord-tui release build for {system}/{machine} — "
122
+ "release-tui.yml's build matrix only covers "
123
+ f"{sorted({v for v in _TARGETS.values()})}. Build from source "
124
+ "instead: cd tui && cargo build --release --bin coord-tui"
125
+ )
126
+ return target
127
+
128
+
129
+ def asset_filename(target: str) -> str:
130
+ """The exact asset name release-tui.yml's ``Stage artifact`` step
131
+ publishes for *target* (``.exe`` only on the Windows target)."""
132
+ return f"coord-tui-{target}.exe" if target.endswith("-windows") else f"coord-tui-{target}"
133
+
134
+
135
+ def fetch_release_assets(
136
+ version: str,
137
+ *,
138
+ repo: str = DEFAULT_REPO,
139
+ api_base: str = DEFAULT_API_BASE,
140
+ timeout: float = 10.0,
141
+ token: str | None = None,
142
+ ) -> list[ReleaseAsset]:
143
+ """The asset list for the GitHub Release tagged ``v<version>`` (or
144
+ *version* verbatim if it already starts with ``v``). Raises on any
145
+ HTTP/network failure — the caller decides how to present that."""
146
+ import httpx # noqa: PLC0415 — keep import cost off the non-network path
147
+
148
+ tag = version if version.startswith("v") else f"v{version}"
149
+ url = f"{api_base.rstrip('/')}/repos/{repo}/releases/tags/{tag}"
150
+ headers = {"Accept": "application/vnd.github+json"}
151
+ if token:
152
+ headers["Authorization"] = f"Bearer {token}"
153
+ response = httpx.get(url, timeout=timeout, headers=headers, follow_redirects=True)
154
+ response.raise_for_status()
155
+ data = response.json()
156
+
157
+ assets: list[ReleaseAsset] = []
158
+ for raw in data.get("assets") or []:
159
+ name = raw.get("name")
160
+ download_url = raw.get("browser_download_url") or raw.get("url")
161
+ if not name or not download_url:
162
+ continue
163
+ assets.append(ReleaseAsset(name=name, download_url=download_url, size=raw.get("size")))
164
+ return assets
165
+
166
+
167
+ def find_asset(assets: list[ReleaseAsset], target: str) -> ReleaseAsset:
168
+ """The binary asset for *target* among *assets*, or raise."""
169
+ wanted = asset_filename(target)
170
+ for asset in assets:
171
+ if asset.name == wanted:
172
+ return asset
173
+ have = sorted(a.name for a in assets)
174
+ raise ReleaseAssetNotFoundError(
175
+ f"release has no {wanted!r} asset for this platform (have: {have})"
176
+ )
177
+
178
+
179
+ def find_checksum_asset(assets: list[ReleaseAsset], binary_asset: ReleaseAsset) -> ReleaseAsset | None:
180
+ """A ``<binary-name>.sha256`` sibling asset, when the release publishes
181
+ one. PKG-3 does not currently publish checksums, so ``None`` (skip
182
+ verification) is the expected common case, not a failure."""
183
+ wanted = f"{binary_asset.name}.sha256"
184
+ for asset in assets:
185
+ if asset.name == wanted:
186
+ return asset
187
+ return None
188
+
189
+
190
+ def download_asset(url: str, dest_dir: Path, *, timeout: float = 60.0, token: str | None = None) -> Path:
191
+ """Stream *url* into a fresh temp file inside *dest_dir* and return its
192
+ path. On any failure (network error, non-2xx, truncated body) the temp
193
+ file is removed before re-raising — a failed/interrupted download never
194
+ leaves a stray partial file behind, and never touches the eventual
195
+ destination path at all (that only happens in :func:`install_atomically`).
196
+ """
197
+ import httpx # noqa: PLC0415 — keep import cost off the non-network path
198
+
199
+ dest_dir.mkdir(parents=True, exist_ok=True)
200
+ fd, tmp_name = tempfile.mkstemp(prefix=".coord-tui-download-", dir=str(dest_dir))
201
+ tmp_path = Path(tmp_name)
202
+ headers = {}
203
+ if token:
204
+ headers["Authorization"] = f"Bearer {token}"
205
+ try:
206
+ with os.fdopen(fd, "wb") as fh:
207
+ with httpx.stream(
208
+ "GET", url, timeout=timeout, headers=headers, follow_redirects=True
209
+ ) as response:
210
+ response.raise_for_status()
211
+ for chunk in response.iter_bytes():
212
+ fh.write(chunk)
213
+ except BaseException:
214
+ tmp_path.unlink(missing_ok=True)
215
+ raise
216
+ return tmp_path
217
+
218
+
219
+ def sha256_file(path: Path) -> str:
220
+ digest = hashlib.sha256()
221
+ with open(path, "rb") as fh:
222
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
223
+ digest.update(chunk)
224
+ return digest.hexdigest()
225
+
226
+
227
+ def install_atomically(tmp_path: Path, dest_path: Path) -> None:
228
+ """``chmod +x`` *tmp_path*, then rename it directly onto *dest_path*.
229
+
230
+ ``os.replace`` is atomic on POSIX when both paths share a filesystem —
231
+ true here by construction, since :func:`download_asset` is always
232
+ called with *dest_path*'s own parent directory. Executable bits are set
233
+ BEFORE the rename, not after, so the instant a process can see a file at
234
+ *dest_path* it is already a complete, runnable binary.
235
+ """
236
+ mode = tmp_path.stat().st_mode
237
+ tmp_path.chmod(mode | 0o111)
238
+ os.replace(str(tmp_path), str(dest_path))
239
+
240
+
241
+ def read_installed_version(binary_path: Path, timeout: float = 5.0) -> str | None:
242
+ """Parse ``<binary_path> --version``'s ``coord-tui <version>`` output,
243
+ or ``None`` when the path doesn't exist, isn't runnable, or its output
244
+ doesn't match that shape (see ``tui/src/main.rs``'s ``version_string``)."""
245
+ if not binary_path.exists():
246
+ return None
247
+ try:
248
+ # Resolve to an absolute path before exec: a bare relative filename
249
+ # with no directory component (e.g. `Path("coord-tui")`, valid for
250
+ # `.exists()` above) is otherwise looked up on `$PATH` by execve,
251
+ # not treated as "the file right here", and fails with ENOENT.
252
+ result = subprocess.run(
253
+ [str(binary_path.resolve()), "--version"],
254
+ capture_output=True,
255
+ text=True,
256
+ timeout=timeout,
257
+ )
258
+ except (OSError, subprocess.SubprocessError):
259
+ return None
260
+ prefix = "coord-tui "
261
+ for line in (result.stdout + result.stderr).splitlines():
262
+ line = line.strip()
263
+ if line.startswith(prefix):
264
+ return line[len(prefix):].strip() or None
265
+ return None
266
+
267
+
268
+ def is_dev_build(binary_path: Path) -> bool:
269
+ """True when *binary_path* reports the :data:`DEV_BUILD_SENTINEL_VERSION`
270
+ — see the module docstring's "never clobber a dev build" invariant."""
271
+ return read_installed_version(binary_path) == DEV_BUILD_SENTINEL_VERSION