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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. 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 "")