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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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
|