command-gate 0.2.4__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.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/cli/update.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""Discover and install newer cgate versions from GitHub Releases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING, Annotated, Final
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from cgate import __version__
|
|
17
|
+
from cgate.core.paths import data_dir
|
|
18
|
+
from cgate.core.update_log import append_log
|
|
19
|
+
from cgate.update import (
|
|
20
|
+
Release,
|
|
21
|
+
UpdateError,
|
|
22
|
+
compare_versions,
|
|
23
|
+
current_binary_path,
|
|
24
|
+
download_to,
|
|
25
|
+
ensure_helper_binary,
|
|
26
|
+
fetch_latest_release,
|
|
27
|
+
find_blocking_processes,
|
|
28
|
+
find_mcp_serving_pids,
|
|
29
|
+
kill_process,
|
|
30
|
+
replace_binary,
|
|
31
|
+
select_asset,
|
|
32
|
+
spawn_helper,
|
|
33
|
+
verify_attestation,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from cgate.update import Asset
|
|
40
|
+
|
|
41
|
+
update_app = typer.Typer(help="Check for and apply updates from GitHub Releases.")
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
# Brief delay after killing a process so Windows releases the file lock
|
|
45
|
+
# before we retry the rename. Empirically 1s is enough on stock Windows 11;
|
|
46
|
+
# keep it short enough not to feel laggy in interactive use.
|
|
47
|
+
_KILL_SETTLE_SECONDS: float = 1.0
|
|
48
|
+
# How many trailing update.log lines `cgate update status` shows -- enough
|
|
49
|
+
# to see the outcome of the last background op without dumping the whole
|
|
50
|
+
# file's history.
|
|
51
|
+
_STATUS_LOG_LINES: Final = 15
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _print_release_summary(release: Release) -> None:
|
|
55
|
+
console.print(
|
|
56
|
+
f"[bold]Latest:[/bold] cgate {release.version} ([dim]{release.tag}[/dim])"
|
|
57
|
+
)
|
|
58
|
+
console.print(f" {release.html_url}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@update_app.command("check")
|
|
62
|
+
def check_cmd() -> None:
|
|
63
|
+
"""Show whether a newer version is available, without downloading."""
|
|
64
|
+
try:
|
|
65
|
+
release = fetch_latest_release()
|
|
66
|
+
except UpdateError as exc:
|
|
67
|
+
console.print(f"[red]Could not check for updates:[/red] {exc}")
|
|
68
|
+
raise typer.Exit(code=1) from exc
|
|
69
|
+
|
|
70
|
+
_print_release_summary(release)
|
|
71
|
+
if compare_versions(__version__, release.version) >= 0:
|
|
72
|
+
console.print(f"[green]cgate {__version__} is up to date.[/green]")
|
|
73
|
+
return
|
|
74
|
+
console.print(
|
|
75
|
+
f"[yellow]Update available: {__version__} -> {release.version}[/yellow]"
|
|
76
|
+
)
|
|
77
|
+
console.print("Run [bold]cgate update apply[/bold] to install.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@update_app.command("status")
|
|
81
|
+
def status_cmd() -> None:
|
|
82
|
+
"""Show the current version and the outcome of the last background update/delete.
|
|
83
|
+
|
|
84
|
+
A deferred swap/delete (see ``_swap_via_helper_or_fallback`` /
|
|
85
|
+
``cli.uninstall._delete_via_helper_or_fallback``) finishes after this
|
|
86
|
+
process has already exited, so there was previously no way to check
|
|
87
|
+
whether it actually succeeded short of re-running ``cgate --version``
|
|
88
|
+
and eyeballing the number. This surfaces the existing signals --
|
|
89
|
+
staged files and ``update.log`` -- rather than tracking job state.
|
|
90
|
+
"""
|
|
91
|
+
console.print(f"cgate {__version__}")
|
|
92
|
+
|
|
93
|
+
binary = current_binary_path()
|
|
94
|
+
if binary is not None:
|
|
95
|
+
staging = binary.with_name(binary.name + ".new")
|
|
96
|
+
previous = binary.with_name(binary.name + ".previous")
|
|
97
|
+
if staging.exists():
|
|
98
|
+
console.print(
|
|
99
|
+
f"[yellow]Staged download present:[/yellow] {staging}\n"
|
|
100
|
+
"[dim]An update may still be finishing in the background, "
|
|
101
|
+
"or a prior one didn't complete.[/dim]"
|
|
102
|
+
)
|
|
103
|
+
if previous.exists():
|
|
104
|
+
console.print(f"[dim]Rollback snapshot present:[/dim] {previous}")
|
|
105
|
+
|
|
106
|
+
log_path = data_dir() / "update.log"
|
|
107
|
+
if not log_path.exists():
|
|
108
|
+
console.print("[dim]No update.log yet -- no background update/uninstall has run.[/dim]")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
lines = log_path.read_text(encoding="utf-8").splitlines()
|
|
112
|
+
tail = lines[-_STATUS_LOG_LINES:]
|
|
113
|
+
console.print(f"\n[bold]Last {len(tail)} line(s) of {log_path}:[/bold]")
|
|
114
|
+
for line in tail:
|
|
115
|
+
console.print(f" {line}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _download_and_verify(asset: Asset, release: Release, staging: Path) -> None:
|
|
119
|
+
"""Download the release asset and verify its build-provenance attestation.
|
|
120
|
+
|
|
121
|
+
Split out of ``apply_cmd`` to keep its branch/statement count from
|
|
122
|
+
growing further -- the attestation check (issue #4) adds a second
|
|
123
|
+
verification step on top of the existing digest check in
|
|
124
|
+
``download_to``, same reasoning that pulled out
|
|
125
|
+
``_attempt_swap_with_recovery`` during the reopen-issues pass. Raises
|
|
126
|
+
``typer.Exit(code=3)`` on either failure; a failed attestation also
|
|
127
|
+
wipes ``staging`` so a rejected binary isn't left on disk.
|
|
128
|
+
"""
|
|
129
|
+
console.print(f"Downloading {asset.name} ({asset.size / 1024 / 1024:.1f} MB)...")
|
|
130
|
+
try:
|
|
131
|
+
download_to(asset, staging)
|
|
132
|
+
except UpdateError as exc:
|
|
133
|
+
console.print(f"[red]Download failed:[/red] {exc}")
|
|
134
|
+
raise typer.Exit(code=3) from exc
|
|
135
|
+
|
|
136
|
+
console.print("Verifying build provenance attestation...")
|
|
137
|
+
try:
|
|
138
|
+
verify_attestation(asset, release)
|
|
139
|
+
except UpdateError as exc:
|
|
140
|
+
console.print(f"[red]Attestation verification failed:[/red] {exc}")
|
|
141
|
+
console.print(
|
|
142
|
+
"[dim]Refusing to install a binary that cannot be verified as "
|
|
143
|
+
f"coming from our release workflow. See {release.html_url} to "
|
|
144
|
+
"inspect the release manually.[/dim]"
|
|
145
|
+
)
|
|
146
|
+
_safe_unlink(staging)
|
|
147
|
+
raise typer.Exit(code=3) from exc
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@update_app.command("apply")
|
|
151
|
+
def apply_cmd(
|
|
152
|
+
*,
|
|
153
|
+
force: Annotated[
|
|
154
|
+
bool,
|
|
155
|
+
typer.Option(
|
|
156
|
+
"--force",
|
|
157
|
+
"-f",
|
|
158
|
+
help=(
|
|
159
|
+
"Auto-kill running cgate processes that block the swap, "
|
|
160
|
+
"without prompting. Does NOT cover a process serving a live "
|
|
161
|
+
"MCP session -- see --force-mcp. Required for fully "
|
|
162
|
+
"unattended updates otherwise."
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
] = False,
|
|
166
|
+
force_mcp: Annotated[
|
|
167
|
+
bool,
|
|
168
|
+
typer.Option(
|
|
169
|
+
"--force-mcp",
|
|
170
|
+
help=(
|
|
171
|
+
"Also kill a blocking process that is serving a live MCP "
|
|
172
|
+
"session for an IA client, without prompting. This "
|
|
173
|
+
"immediately disconnects that client mid-session; only pass "
|
|
174
|
+
"it when you know nothing is relying on the connection."
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
] = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Download, verify, and replace the running binary with the latest release."""
|
|
180
|
+
binary = current_binary_path()
|
|
181
|
+
if binary is None:
|
|
182
|
+
# Running from source: there's no staged swap to do, but the
|
|
183
|
+
# download/install instructions still apply.
|
|
184
|
+
try:
|
|
185
|
+
release = fetch_latest_release()
|
|
186
|
+
except UpdateError as exc:
|
|
187
|
+
console.print(f"[red]Could not fetch updates:[/red] {exc}")
|
|
188
|
+
raise typer.Exit(code=1) from exc
|
|
189
|
+
asset = select_asset(release)
|
|
190
|
+
detail = "(not a PyInstaller binary).[/yellow]"
|
|
191
|
+
message = f"[yellow]Cannot auto-install from a development environment {detail}"
|
|
192
|
+
console.print(message)
|
|
193
|
+
console.print(f"Download manually from: {release.html_url}")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# From here on, `binary`, `staging`, `previous` are well-defined.
|
|
197
|
+
staging = binary.with_name(binary.name + ".new")
|
|
198
|
+
previous = binary.with_name(binary.name + ".previous")
|
|
199
|
+
# Surface leftover staged files BEFORE any early-exit so the user sees
|
|
200
|
+
# them even when already up-to-date or the version check decides not
|
|
201
|
+
# to proceed (issue #15).
|
|
202
|
+
_warn_about_orphans(staging, previous)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
release = fetch_latest_release()
|
|
206
|
+
except UpdateError as exc:
|
|
207
|
+
console.print(f"[red]Could not fetch updates:[/red] {exc}")
|
|
208
|
+
raise typer.Exit(code=1) from exc
|
|
209
|
+
|
|
210
|
+
if compare_versions(__version__, release.version) >= 0:
|
|
211
|
+
console.print(f"[green]cgate {__version__} is already up to date.[/green]")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
asset = select_asset(release)
|
|
215
|
+
if asset is None:
|
|
216
|
+
available = ", ".join(item.name for item in release.assets) or "(none)"
|
|
217
|
+
message = "".join(
|
|
218
|
+
(
|
|
219
|
+
"[red]No binary for this platform in release ",
|
|
220
|
+
f"{release.tag}. Available: {available}[/red]",
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
console.print(message)
|
|
224
|
+
raise typer.Exit(code=2)
|
|
225
|
+
|
|
226
|
+
_download_and_verify(asset, release, staging)
|
|
227
|
+
|
|
228
|
+
# Read the running binary to a `.previous` rollback slot before the swap
|
|
229
|
+
# so a bad release can be reverted with a single rename. Copy (not move)
|
|
230
|
+
# because the source may be locked for write but is readable on Windows.
|
|
231
|
+
try:
|
|
232
|
+
if previous.exists():
|
|
233
|
+
previous.unlink()
|
|
234
|
+
shutil.copyfile(binary, previous)
|
|
235
|
+
rollback_msg = f"Rollback slot: [dim]{previous}[/dim]"
|
|
236
|
+
except OSError as exc:
|
|
237
|
+
rollback_msg = f"[yellow]Could not snapshot current binary:[/yellow] {exc}"
|
|
238
|
+
|
|
239
|
+
swap_handled_locally = False
|
|
240
|
+
try:
|
|
241
|
+
err = replace_binary(staging, binary)
|
|
242
|
+
if err is None:
|
|
243
|
+
console.print(
|
|
244
|
+
f"[green]Installed cgate {release.version}.[/green]\n"
|
|
245
|
+
"[dim]Restart your IA client (Claude Code / opencode / Cursor) "
|
|
246
|
+
"to load the new MCP server.[/dim]\n"
|
|
247
|
+
f"{rollback_msg}"
|
|
248
|
+
)
|
|
249
|
+
swap_handled_locally = True
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
_attempt_swap_with_recovery(
|
|
253
|
+
staging=staging,
|
|
254
|
+
binary=binary,
|
|
255
|
+
rollback_msg=rollback_msg,
|
|
256
|
+
release=release,
|
|
257
|
+
force=force,
|
|
258
|
+
force_mcp=force_mcp,
|
|
259
|
+
)
|
|
260
|
+
# If the helper returns without raising, the swap succeeded.
|
|
261
|
+
swap_handled_locally = True
|
|
262
|
+
except typer.Exit:
|
|
263
|
+
# Manual recovery footer (code 4) cleaned `.previous` itself and
|
|
264
|
+
# intentionally keeps `.new` for the user to move. Nothing to do.
|
|
265
|
+
swap_handled_locally = True
|
|
266
|
+
raise
|
|
267
|
+
except BaseException:
|
|
268
|
+
# Truly unexpected: between staging being written and a clean
|
|
269
|
+
# handled exit, state is ambiguous. Wipe both staged files so we
|
|
270
|
+
# don't leak orphans (issue #15). The exception propagates so the
|
|
271
|
+
# user still sees the underlying error.
|
|
272
|
+
if not swap_handled_locally:
|
|
273
|
+
_safe_unlink(staging)
|
|
274
|
+
_safe_unlink(previous)
|
|
275
|
+
raise
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _attempt_swap_with_recovery(
|
|
279
|
+
*,
|
|
280
|
+
staging: Path,
|
|
281
|
+
binary: Path,
|
|
282
|
+
rollback_msg: str,
|
|
283
|
+
release: Release,
|
|
284
|
+
force: bool,
|
|
285
|
+
force_mcp: bool,
|
|
286
|
+
) -> None:
|
|
287
|
+
"""On a failed direct swap, branch by blocker identity.
|
|
288
|
+
|
|
289
|
+
Returns silently when a recovery path actually finished the install
|
|
290
|
+
(delayed swap queued, or kill+retry succeeded). Otherwise prints the
|
|
291
|
+
manual-recovery footer (which cleans ``staging.previous`` and points
|
|
292
|
+
the user at ``staging``) and raises ``typer.Exit(code=4)``.
|
|
293
|
+
|
|
294
|
+
Branch order:
|
|
295
|
+
- Self serving MCP: loud warning + --force-mcp gate, then spawn helper.
|
|
296
|
+
- Other cgate processes blocking: kill+retry, with their own MCP check.
|
|
297
|
+
- Self only (Windows): straight delayed swap.
|
|
298
|
+
- Nobody: genuine OS-level swap failure -> manual recovery.
|
|
299
|
+
"""
|
|
300
|
+
release_version = release.version
|
|
301
|
+
err = replace_binary(staging, binary)
|
|
302
|
+
if err is None:
|
|
303
|
+
console.print(
|
|
304
|
+
f"[green]Installed cgate {release_version}.[/green]\n"
|
|
305
|
+
"[dim]Restart your IA client (Claude Code / opencode / Cursor) "
|
|
306
|
+
"to load the new MCP server.[/dim]\n"
|
|
307
|
+
f"{rollback_msg}"
|
|
308
|
+
)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
console.print(f"[red]Could not replace the running binary:[/red] {err}")
|
|
312
|
+
self_pid = os.getpid()
|
|
313
|
+
# Exclude our own PID: on Windows tasklist always finds the running
|
|
314
|
+
# executable under its own name, so without exclude_pid we'd always
|
|
315
|
+
# take the self branch -- which previously hid the MCP-warning elif
|
|
316
|
+
# for self-lock, the very scenario issue #19 calls out.
|
|
317
|
+
blockers = find_blocking_processes(binary, exclude_pid=self_pid)
|
|
318
|
+
# Check MCP across self + blockers once; reuse the split below.
|
|
319
|
+
mcp_pids = find_mcp_serving_pids([self_pid, *blockers])
|
|
320
|
+
self_is_mcp = self_pid in mcp_pids
|
|
321
|
+
other_mcp = [pid for pid in mcp_pids if pid != self_pid]
|
|
322
|
+
|
|
323
|
+
if self_is_mcp:
|
|
324
|
+
# We are serving MCP for a live IA-client session. Killing this
|
|
325
|
+
# process disconnects that session immediately -- same warning
|
|
326
|
+
# the elif raises for other-process MCP servers (issue #19).
|
|
327
|
+
console.print(
|
|
328
|
+
f"[red]The running cgate process (PID {self_pid}) is currently "
|
|
329
|
+
"serving a live MCP session for an IA client (Claude Code / "
|
|
330
|
+
"opencode / Cursor). Killing it disconnects that session "
|
|
331
|
+
"immediately: any in-flight tool call fails, and cgate cannot "
|
|
332
|
+
"reconnect it for you -- you will need to restart the IA "
|
|
333
|
+
"client afterward.[/red]"
|
|
334
|
+
)
|
|
335
|
+
proceed = force_mcp or typer.confirm(
|
|
336
|
+
"Proceed with self-swap and disconnect the live MCP session?",
|
|
337
|
+
default=False,
|
|
338
|
+
)
|
|
339
|
+
if proceed:
|
|
340
|
+
if _swap_via_helper_or_fallback(
|
|
341
|
+
staging, binary, wait_pids=[self_pid], release=release
|
|
342
|
+
):
|
|
343
|
+
console.print(
|
|
344
|
+
"[green]Update staged.[/green] The move will complete "
|
|
345
|
+
"in the background after this process exits. Re-run "
|
|
346
|
+
f"[bold]cgate --version[/bold] in a few seconds to "
|
|
347
|
+
f"confirm.\n{rollback_msg}"
|
|
348
|
+
)
|
|
349
|
+
return
|
|
350
|
+
console.print("[red]Could not spawn swap helper.[/red]")
|
|
351
|
+
# declined or spawn failed -> fall through to manual recovery
|
|
352
|
+
elif blockers:
|
|
353
|
+
pid_list = ", ".join(str(pid) for pid in blockers)
|
|
354
|
+
console.print(
|
|
355
|
+
f"[yellow]Active cgate processes blocking the swap:[/yellow] "
|
|
356
|
+
f"PID(s) {pid_list}"
|
|
357
|
+
)
|
|
358
|
+
if other_mcp:
|
|
359
|
+
mcp_pid_list = ", ".join(str(pid) for pid in other_mcp)
|
|
360
|
+
console.print(
|
|
361
|
+
f"[red]PID(s) {mcp_pid_list} appear to be serving a live MCP "
|
|
362
|
+
"session for an IA client (Claude Code / opencode / Cursor). "
|
|
363
|
+
"Killing it disconnects that session immediately: any "
|
|
364
|
+
"in-flight tool call fails, and cgate cannot reconnect it "
|
|
365
|
+
"for you -- you will need to restart the IA client "
|
|
366
|
+
"afterward.[/red]"
|
|
367
|
+
)
|
|
368
|
+
proceed = force_mcp or typer.confirm(
|
|
369
|
+
"Kill the live MCP session and retry the swap?", default=False
|
|
370
|
+
)
|
|
371
|
+
else:
|
|
372
|
+
proceed = force or typer.confirm(
|
|
373
|
+
"Kill blocking processes and retry the swap?", default=False
|
|
374
|
+
)
|
|
375
|
+
if proceed:
|
|
376
|
+
killed = [pid for pid in blockers if kill_process(pid)]
|
|
377
|
+
if killed:
|
|
378
|
+
console.print(
|
|
379
|
+
f"[green]Killed {len(killed)} process(es); "
|
|
380
|
+
f"waiting {_KILL_SETTLE_SECONDS:g}s for Windows to "
|
|
381
|
+
f"release locks...[/green]"
|
|
382
|
+
)
|
|
383
|
+
time.sleep(_KILL_SETTLE_SECONDS)
|
|
384
|
+
err = replace_binary(staging, binary)
|
|
385
|
+
if err is None:
|
|
386
|
+
console.print(
|
|
387
|
+
f"[green]Installed cgate {release_version}.[/green]\n"
|
|
388
|
+
"[dim]Restart your IA client to load the new MCP "
|
|
389
|
+
f"server.[/dim]\n{rollback_msg}"
|
|
390
|
+
)
|
|
391
|
+
return
|
|
392
|
+
console.print(f"[red]Swap still failed after kill:[/red] {err}")
|
|
393
|
+
elif sys.platform == "win32":
|
|
394
|
+
# No other cgate processes and we aren't ourselves serving MCP.
|
|
395
|
+
# On Windows the running executable is always locked by this
|
|
396
|
+
# process, so the most likely cause is plain self-lock.
|
|
397
|
+
if _swap_via_helper_or_fallback(
|
|
398
|
+
staging, binary, wait_pids=[self_pid], release=release
|
|
399
|
+
):
|
|
400
|
+
console.print(
|
|
401
|
+
"[green]Update staged.[/green] The move will complete "
|
|
402
|
+
"in the background after this process exits. Re-run "
|
|
403
|
+
f"[bold]cgate --version[/bold] in a few seconds to confirm.\n"
|
|
404
|
+
f"{rollback_msg}"
|
|
405
|
+
)
|
|
406
|
+
return
|
|
407
|
+
console.print("[red]Could not spawn swap helper.[/red]")
|
|
408
|
+
# else: non-Windows with no blockers -- genuine OS-level swap failure
|
|
409
|
+
# (permissions, AV, etc.). Fall through to manual recovery.
|
|
410
|
+
|
|
411
|
+
previous = binary.with_name(binary.name + ".previous")
|
|
412
|
+
console.print(
|
|
413
|
+
"\n[yellow]Manual recovery:[/yellow]\n"
|
|
414
|
+
f" [dim]We'll auto-apply this update the next time you run cgate "
|
|
415
|
+
f"with no other cgate processes alive. Easiest way there:\n"
|
|
416
|
+
f" close every IA client connected to cgate (Claude Code / opencode / Cursor),\n"
|
|
417
|
+
f" or restart your computer. No action needed unless you want to apply now:[/dim]\n"
|
|
418
|
+
f" 1. Stop every running 'cgate' process:\n"
|
|
419
|
+
f" [dim]taskkill /F /IM cgate.exe[/dim]\n"
|
|
420
|
+
f" 2. Replace the binary:\n"
|
|
421
|
+
f" [dim]Move-Item -Force '{staging}' '{binary}'[/dim]\n"
|
|
422
|
+
f" 3. Restart your IA client (Claude Code / opencode / Cursor)\n"
|
|
423
|
+
f"\nStaged download: [bold]{staging}[/bold]"
|
|
424
|
+
)
|
|
425
|
+
# The swap never happened on any path that reaches here, so `previous`
|
|
426
|
+
# is just a redundant copy of the still-current, unreplaced binary --
|
|
427
|
+
# not a real rollback slot. Clean it up rather than leaving it as
|
|
428
|
+
# disk clutter (issue #15); `staging` stays, since the message above
|
|
429
|
+
# points the user at it for the manual move.
|
|
430
|
+
previous.unlink(missing_ok=True)
|
|
431
|
+
raise typer.Exit(code=4)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _swap_via_helper_or_fallback(
|
|
435
|
+
staging: Path, binary: Path, *, wait_pids: list[int], release: Release
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""Prefer the compiled ``cgate-helper.exe`` to perform the swap.
|
|
438
|
+
|
|
439
|
+
Falls back to the ``cmd.exe`` shell-chain when it isn't available or
|
|
440
|
+
fails to spawn. The compiled helper does a real wait-for-exit on
|
|
441
|
+
``wait_pids`` (not the shell-chain's blind ~4s ``ping`` delay) and
|
|
442
|
+
never shares an image name with ``cgate.exe``, so there is nothing to
|
|
443
|
+
disambiguate on Windows's process list. ``release`` pairs the
|
|
444
|
+
helper's version with the cgate version being installed
|
|
445
|
+
(``ensure_helper_binary`` downloads and attestation-verifies it from
|
|
446
|
+
that same release on first use).
|
|
447
|
+
"""
|
|
448
|
+
helper = ensure_helper_binary(binary, release)
|
|
449
|
+
if helper is not None:
|
|
450
|
+
wait_args = [arg for pid in wait_pids for arg in ("--wait-pid", str(pid))]
|
|
451
|
+
spawned = spawn_helper(
|
|
452
|
+
helper, "replace", "--target", str(binary), "--source", str(staging), *wait_args
|
|
453
|
+
)
|
|
454
|
+
if spawned:
|
|
455
|
+
append_log(f"update apply: dispatched compiled helper {helper} for {binary}")
|
|
456
|
+
return True
|
|
457
|
+
append_log("update apply: compiled helper spawn failed, falling back to shell-chain")
|
|
458
|
+
return _spawn_delayed_swap(staging, binary)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _spawn_delayed_swap(staging, target) -> bool:
|
|
462
|
+
"""Spawn a detached subprocess that moves ``staging`` to ``target``
|
|
463
|
+
after the caller has had time to exit.
|
|
464
|
+
|
|
465
|
+
Windows: ``cmd.exe /c "ping ... && move"`` because cmd.exe is not
|
|
466
|
+
cgate.exe and therefore does not hold the file lock. The ``ping``
|
|
467
|
+
burns ~4 seconds to let the caller fully release its handle on
|
|
468
|
+
``target``.
|
|
469
|
+
|
|
470
|
+
POSIX: ``mv -f`` via start_new_session, no delay needed since there
|
|
471
|
+
is no self-lock.
|
|
472
|
+
"""
|
|
473
|
+
try:
|
|
474
|
+
if sys.platform == "win32":
|
|
475
|
+
# cmd.exe is the cleanest available process to do a move on
|
|
476
|
+
# Windows. ``ping`` with -n 5 sends 4 pings (about 3-4s) and
|
|
477
|
+
# exits 0; ``&`` chains commands. The quotes around paths
|
|
478
|
+
# matter because Windows paths with spaces would otherwise
|
|
479
|
+
# be split.
|
|
480
|
+
cmd_str = (
|
|
481
|
+
f'ping -n 5 127.0.0.1 > nul & '
|
|
482
|
+
f'move /Y "{staging}" "{target}"'
|
|
483
|
+
)
|
|
484
|
+
subprocess.Popen(
|
|
485
|
+
f"cmd.exe /c \"{cmd_str}\"",
|
|
486
|
+
# DETACHED_PROCESS | CREATE_NO_WINDOW: detach from our console
|
|
487
|
+
# AND suppress the new console Windows would otherwise open
|
|
488
|
+
# for cmd.exe/ping.exe. DETACHED_PROCESS alone still flashes
|
|
489
|
+
# a visible window since it only stops console inheritance,
|
|
490
|
+
# not allocation of a fresh one.
|
|
491
|
+
creationflags=0x00000008 | 0x08000000,
|
|
492
|
+
stdout=subprocess.DEVNULL,
|
|
493
|
+
stderr=subprocess.DEVNULL,
|
|
494
|
+
close_fds=True,
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
subprocess.Popen(
|
|
498
|
+
["mv", "-f", str(staging), str(target)],
|
|
499
|
+
start_new_session=True,
|
|
500
|
+
stdout=subprocess.DEVNULL,
|
|
501
|
+
stderr=subprocess.DEVNULL,
|
|
502
|
+
close_fds=True,
|
|
503
|
+
)
|
|
504
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
505
|
+
append_log(f"update apply: failed to spawn delayed swap: {exc}")
|
|
506
|
+
return False
|
|
507
|
+
return True
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _safe_unlink(path: Path) -> None:
|
|
511
|
+
"""Best-effort removal of a staged update file (issue #15).
|
|
512
|
+
|
|
513
|
+
Swallows missing-file and permission/AV errors; the goal here is
|
|
514
|
+
to avoid leaving orphans on disk, not to surface every failure.
|
|
515
|
+
"""
|
|
516
|
+
with contextlib.suppress(OSError):
|
|
517
|
+
path.unlink(missing_ok=True)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _warn_about_orphans(staging: Path, previous: Path) -> None:
|
|
521
|
+
"""Surface leftover staged files from a previous run (issue #15).
|
|
522
|
+
|
|
523
|
+
The download path silently overwrites ``staging`` and the snapshot
|
|
524
|
+
step silently unlinks ``previous``, both of which hid the fact that
|
|
525
|
+
a prior ``update apply`` exited without finishing. Warn the user so
|
|
526
|
+
they can investigate; we still proceed (and overwrite) so a fresh
|
|
527
|
+
attempt isn't blocked by stale leftovers.
|
|
528
|
+
"""
|
|
529
|
+
if staging.exists():
|
|
530
|
+
console.print(
|
|
531
|
+
f"[yellow]Found leftover staged download from a previous run:[/yellow]\n"
|
|
532
|
+
f" [dim]{staging}[/dim] -- this run will overwrite it."
|
|
533
|
+
)
|
|
534
|
+
if previous.exists():
|
|
535
|
+
console.print(
|
|
536
|
+
f"[yellow]Found leftover rollback snapshot from a previous run:[/yellow]\n"
|
|
537
|
+
f" [dim]{previous}[/dim] -- this run will overwrite it."
|
|
538
|
+
)
|
cgate/cli/watch.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""CLI group for the interactive approval queue (`cgate watch`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from cgate.core.paths import db_path
|
|
8
|
+
from cgate.db.connection import Database, init_database
|
|
9
|
+
from cgate.watch import run_watch_session
|
|
10
|
+
|
|
11
|
+
watch_app = typer.Typer(help="Interactive approval queue for proposed commands.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@watch_app.callback(invoke_without_command=True)
|
|
15
|
+
def main(ctx: typer.Context) -> None:
|
|
16
|
+
"""Run the watcher session when no future subcommand was selected."""
|
|
17
|
+
if ctx.invoked_subcommand is None:
|
|
18
|
+
db = Database(path=db_path())
|
|
19
|
+
init_database(db)
|
|
20
|
+
run_watch_session(db)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Credential storage in OS keyring and Windows kerberos passthrough detection.
|
|
2
|
+
|
|
3
|
+
Credentials use service ``command-gate`` and key ``connection:{alias}``. The JSON
|
|
4
|
+
payload contains the username, password, and SSH key.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import keyring
|
|
15
|
+
from keyring.errors import KeyringError, PasswordDeleteError
|
|
16
|
+
|
|
17
|
+
KEYRING_SERVICE = "command-gate"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class StoredCredential:
|
|
22
|
+
"""A credential retrieved from the OS keyring for a connection alias."""
|
|
23
|
+
|
|
24
|
+
username: str
|
|
25
|
+
password: str | None
|
|
26
|
+
ssh_key: str | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _key_for(alias: str) -> str:
|
|
30
|
+
return f"connection:{alias}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serialize(username: str, password: str | None, ssh_key: str | None) -> str:
|
|
34
|
+
return json.dumps(
|
|
35
|
+
{"username": username, "password": password, "ssh_key": ssh_key},
|
|
36
|
+
separators=(",", ":"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _deserialize(raw: str) -> StoredCredential:
|
|
41
|
+
data = json.loads(raw)
|
|
42
|
+
return StoredCredential(
|
|
43
|
+
username=str(data["username"]),
|
|
44
|
+
password=data.get("password"),
|
|
45
|
+
ssh_key=data.get("ssh_key"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def store_credential(
|
|
50
|
+
alias: str, *, username: str, password: str | None, ssh_key: str | None
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Store a connection credential in the native OS keyring."""
|
|
53
|
+
keyring.set_password(
|
|
54
|
+
KEYRING_SERVICE, _key_for(alias), _serialize(username, password, ssh_key)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_credential(alias: str) -> StoredCredential | None:
|
|
59
|
+
"""Return the stored credential for an alias, or None if absent."""
|
|
60
|
+
raw = keyring.get_password(KEYRING_SERVICE, _key_for(alias))
|
|
61
|
+
if raw is None:
|
|
62
|
+
return None
|
|
63
|
+
return _deserialize(raw)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def remove_credential(alias: str) -> bool:
|
|
67
|
+
"""Delete a credential and report whether deletion succeeded."""
|
|
68
|
+
try:
|
|
69
|
+
keyring.delete_password(KEYRING_SERVICE, _key_for(alias))
|
|
70
|
+
except (PasswordDeleteError, KeyringError):
|
|
71
|
+
return False
|
|
72
|
+
else:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_kerberos_available() -> bool:
|
|
77
|
+
"""Best-effort detect Windows domain membership for kerberos passthrough."""
|
|
78
|
+
if sys.platform != "win32":
|
|
79
|
+
return False
|
|
80
|
+
executable = shutil.which("wmic")
|
|
81
|
+
if executable is None:
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
result = subprocess.run( # noqa: S603 -- executable resolved by shutil.which
|
|
85
|
+
[executable, "computersystem", "get", "PartOfDomain", "/value"],
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=2,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
92
|
+
return False
|
|
93
|
+
return "PartOfDomain=TRUE" in (result.stdout or "")
|