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/db/types.py ADDED
@@ -0,0 +1,77 @@
1
+ """Domain types for the database layer: enums, NewType IDs, and frozen dataclasses."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from enum import StrEnum
6
+ from typing import TYPE_CHECKING, NewType
7
+
8
+ if TYPE_CHECKING:
9
+ from datetime import datetime
10
+
11
+
12
+ class ServerType(StrEnum):
13
+ """Type of a target server. Drives dialect (PowerShell vs Bash) and exec backend."""
14
+
15
+ WINDOWS = "windows"
16
+ LINUX = "linux"
17
+
18
+
19
+ class CommandStatus(StrEnum):
20
+ """Lifecycle of a single command in a batch.
21
+
22
+ Flow: PENDING -> APPROVED -> EXECUTED (or PENDING -> REJECTED / FAILED).
23
+ APPROVED and PENDING are non-terminal (resolved_at IS NULL).
24
+ """
25
+
26
+ PENDING = "pending"
27
+ APPROVED = "approved"
28
+ REJECTED = "rejected"
29
+ EXECUTED = "executed"
30
+ FAILED = "failed"
31
+
32
+
33
+ BatchId = NewType("BatchId", str)
34
+ CommandId = NewType("CommandId", str)
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Batch:
39
+ """A batch of related commands proposed together by one AI invocation."""
40
+
41
+ id: BatchId
42
+ title: str
43
+ description: str | None
44
+ requested_by_agent: str | None
45
+ created_at: datetime
46
+ resolved_at: datetime | None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class Command:
51
+ """A single command within a batch, addressed to one server."""
52
+
53
+ id: CommandId
54
+ batch_id: BatchId
55
+ position: int
56
+ server_alias: str
57
+ server_type: ServerType
58
+ command: str
59
+ status: CommandStatus
60
+ result: str | None
61
+ approved_by: str | None
62
+ created_at: datetime
63
+ resolved_at: datetime | None
64
+ reason: str | None
65
+ risk_label: str | None
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class Connection:
70
+ """A saved connection from this machine to a remote server, identified by alias."""
71
+
72
+ alias: str
73
+ hostname: str
74
+ server_type: ServerType
75
+ detection_ssh: bool
76
+ detection_winrm: bool
77
+ created_at: datetime
@@ -0,0 +1,7 @@
1
+ """Remote command execution: WinRM for Windows, SSH for Linux."""
2
+ from __future__ import annotations
3
+
4
+ from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
5
+ from cgate.executor.selector import execute_command
6
+
7
+ __all__ = ["DEFAULT_TIMEOUT_SECONDS", "ErrorKind", "ExecutionResult", "execute_command"]
cgate/executor/base.py ADDED
@@ -0,0 +1,71 @@
1
+ """Domain types and exceptions for the executor layer."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+ from typing import TYPE_CHECKING, Final
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Callable
11
+
12
+ DEFAULT_TIMEOUT_SECONDS: Final = 30.0
13
+
14
+
15
+ class ErrorKind(StrEnum):
16
+ """How a command execution failed (None on success)."""
17
+
18
+ TIMEOUT = "timeout"
19
+ AUTH_FAILED = "auth_failed"
20
+ HOST_UNREACHABLE = "host_unreachable"
21
+ PROTOCOL_ERROR = "protocol_error"
22
+
23
+
24
+ class ExecutorError(Exception):
25
+ """Base for executor errors that should be surfaced as a structured result."""
26
+
27
+
28
+ class CommandTimeout(ExecutorError): # noqa: N818 - public name required by executor API
29
+ """Execution exceeded the timeout."""
30
+
31
+
32
+ class AuthenticationFailed(ExecutorError): # noqa: N818 - public name required by executor API
33
+ """Credentials were rejected by the target."""
34
+
35
+
36
+ class HostUnreachable(ExecutorError): # noqa: N818 - public name required by executor API
37
+ """The target host refused or failed the TCP connection."""
38
+
39
+
40
+ class ProtocolError(ExecutorError):
41
+ """The remote server returned malformed output."""
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ExecutionResult:
46
+ """Outcome of running one command on one server.
47
+
48
+ ``error_kind`` is None on any successful connection, regardless of exit code.
49
+ Use ``ok`` to require both a zero exit code and no executor error.
50
+ """
51
+
52
+ stdout: str
53
+ stderr: str
54
+ exit_code: int
55
+ duration_ms: int
56
+ error_kind: ErrorKind | None
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ """Return true exactly when execution succeeded with a zero exit code."""
61
+ return self.error_kind is None and self.exit_code == 0
62
+
63
+
64
+ def measure_duration_ms() -> tuple[int, Callable[[], int]]:
65
+ """Return the monotonic start in milliseconds and an elapsed-time callable."""
66
+ start = time.monotonic()
67
+
68
+ def elapsed() -> int:
69
+ return int((time.monotonic() - start) * 1000)
70
+
71
+ return int(start * 1000), elapsed
@@ -0,0 +1,61 @@
1
+ """Top-level dispatch for executing a command on a saved connection."""
2
+ from __future__ import annotations
3
+
4
+ from cgate.connections.auth import StoredCredential, get_credential, is_kerberos_available
5
+ from cgate.db.types import Connection, ServerType
6
+ from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
7
+ from cgate.executor.ssh import execute_linux
8
+ from cgate.executor.winrm import execute_windows
9
+
10
+
11
+ def execute_command(
12
+ connection: Connection,
13
+ command: str,
14
+ *,
15
+ credential: StoredCredential | None = None,
16
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
17
+ ) -> ExecutionResult:
18
+ """Execute a command on a saved connection using its selected backend."""
19
+ resolved_credential = credential
20
+ if resolved_credential is None:
21
+ resolved_credential = get_credential(connection.alias)
22
+ if resolved_credential is None:
23
+ return _missing_credential_result(connection)
24
+
25
+ match connection.server_type:
26
+ case ServerType.WINDOWS:
27
+ return execute_windows(
28
+ connection.hostname,
29
+ command,
30
+ resolved_credential,
31
+ timeout=timeout,
32
+ )
33
+ case ServerType.LINUX:
34
+ return execute_linux(
35
+ connection.hostname,
36
+ command,
37
+ resolved_credential,
38
+ timeout=timeout,
39
+ )
40
+
41
+
42
+ def _missing_credential_result(connection: Connection) -> ExecutionResult:
43
+ match connection.server_type:
44
+ case ServerType.WINDOWS:
45
+ kerberos_available = is_kerberos_available()
46
+ case ServerType.LINUX:
47
+ kerberos_available = False
48
+ if kerberos_available:
49
+ message = (
50
+ "kerberos passthrough available, but Phase 1 doesn't ship kerberos transport; "
51
+ "store an NTLM credential with `cgate connections add` first."
52
+ )
53
+ else:
54
+ message = f"no credential for alias '{connection.alias}'"
55
+ return ExecutionResult(
56
+ stdout="",
57
+ stderr=message,
58
+ exit_code=-1,
59
+ duration_ms=0,
60
+ error_kind=ErrorKind.AUTH_FAILED,
61
+ )
cgate/executor/ssh.py ADDED
@@ -0,0 +1,157 @@
1
+ """Linux command execution over SSH (paramiko)."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import time
6
+ from typing import TYPE_CHECKING, Final
7
+
8
+ import paramiko
9
+ from paramiko.ssh_exception import NoValidConnectionsError
10
+ from typing_extensions import override
11
+
12
+ from cgate.core.paths import data_dir
13
+ from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from cgate.connections.auth import StoredCredential
19
+
20
+ SSH_PORT: Final = 22
21
+
22
+
23
+ class _TrustOnFirstUsePolicy(paramiko.MissingHostKeyPolicy):
24
+ """Accept an unknown host key once, then pin it to ``known_hosts_path``.
25
+
26
+ Unlike ``AutoAddPolicy`` (issue #6), which trusts every connection with
27
+ no memory at all, paramiko only calls ``missing_host_key`` when the
28
+ hostname isn't already present in the client's loaded host keys --
29
+ if it IS present but the presented key doesn't match, paramiko raises
30
+ ``BadHostKeyException`` on its own before this policy is ever
31
+ consulted. So preloading a persistent file and only auto-trusting
32
+ truly new hosts here gives real trust-on-first-use: a key that
33
+ changes after that first connection (MITM, or the box got rebuilt)
34
+ is rejected instead of silently accepted again.
35
+ """
36
+
37
+ _known_hosts_path: Path # class-level annotation required by strict mode
38
+
39
+ def __init__(self, known_hosts_path: Path) -> None:
40
+ """Remember where to persist newly-trusted host keys."""
41
+ self._known_hosts_path = known_hosts_path
42
+
43
+ @override
44
+ def missing_host_key(
45
+ self, client: paramiko.SSHClient, hostname: str, key: paramiko.PKey
46
+ ) -> None:
47
+ """Trust and persist a host key seen for the first time."""
48
+ client.get_host_keys().add(hostname, key.get_name(), key)
49
+ self._known_hosts_path.parent.mkdir(parents=True, exist_ok=True)
50
+ client.save_host_keys(str(self._known_hosts_path))
51
+
52
+
53
+ def _known_hosts_path() -> Path:
54
+ return data_dir() / "known_hosts"
55
+
56
+
57
+ def execute_linux(
58
+ hostname: str,
59
+ command: str,
60
+ credential: StoredCredential,
61
+ *,
62
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
63
+ ) -> ExecutionResult:
64
+ """Run a command on a host via SSH using a key before a password."""
65
+ started = time.monotonic()
66
+ client = paramiko.SSHClient()
67
+ known_hosts_path = _known_hosts_path()
68
+ if known_hosts_path.exists():
69
+ client.load_host_keys(str(known_hosts_path))
70
+ client.set_missing_host_key_policy(_TrustOnFirstUsePolicy(known_hosts_path))
71
+
72
+ try:
73
+ if credential.ssh_key:
74
+ client.connect(
75
+ hostname,
76
+ port=SSH_PORT,
77
+ username=credential.username,
78
+ key_filename=credential.ssh_key,
79
+ timeout=timeout,
80
+ auth_timeout=timeout,
81
+ banner_timeout=timeout,
82
+ )
83
+ elif credential.password:
84
+ client.connect(
85
+ hostname,
86
+ port=SSH_PORT,
87
+ username=credential.username,
88
+ password=credential.password,
89
+ timeout=timeout,
90
+ auth_timeout=timeout,
91
+ banner_timeout=timeout,
92
+ )
93
+ else:
94
+ return _failure(
95
+ "credential has neither ssh_key nor password",
96
+ ErrorKind.AUTH_FAILED,
97
+ started,
98
+ )
99
+
100
+ _stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
101
+ stdout.channel.settimeout(timeout)
102
+ try:
103
+ out_bytes = stdout.read()
104
+ err_bytes = stderr.read()
105
+ exit_code = stdout.channel.recv_exit_status()
106
+ except Exception as exc: # noqa: BLE001 - Paramiko streams expose generic read errors
107
+ return _failure(str(exc), ErrorKind.TIMEOUT, started)
108
+
109
+ return ExecutionResult(
110
+ stdout=_decode_output(out_bytes),
111
+ stderr=_decode_output(err_bytes),
112
+ exit_code=int(exit_code),
113
+ duration_ms=int((time.monotonic() - started) * 1000),
114
+ error_kind=None,
115
+ )
116
+ except Exception as exc: # noqa: BLE001 - Paramiko connect can surface socket subclasses
117
+ return _failure_from_paramiko_exception(exc, started)
118
+ finally:
119
+ with contextlib.suppress(Exception):
120
+ client.close()
121
+
122
+
123
+ def _decode_output(output: str | bytes | bytearray | None) -> str:
124
+ if isinstance(output, (bytes, bytearray)):
125
+ return output.decode("utf-8", errors="replace")
126
+ return output or ""
127
+
128
+
129
+ def _failure(message: str, error_kind: ErrorKind, started: float) -> ExecutionResult:
130
+ return ExecutionResult(
131
+ stdout="",
132
+ stderr=message,
133
+ exit_code=-1,
134
+ duration_ms=int((time.monotonic() - started) * 1000),
135
+ error_kind=error_kind,
136
+ )
137
+
138
+
139
+ def _failure_from_paramiko_exception(exc: Exception, started: float) -> ExecutionResult:
140
+ return _failure(str(exc), _classify_paramiko_exception(exc), started)
141
+
142
+
143
+ def _classify_paramiko_exception(exc: Exception) -> ErrorKind:
144
+ name = type(exc).__name__.lower()
145
+ message = str(exc).lower()
146
+ if isinstance(exc, paramiko.AuthenticationException) or "auth" in name:
147
+ return ErrorKind.AUTH_FAILED
148
+ if isinstance(exc, NoValidConnectionsError):
149
+ return ErrorKind.HOST_UNREACHABLE
150
+ if any(
151
+ signal in message
152
+ for signal in ("no route to host", "connection refused", "getaddrinfo failed")
153
+ ):
154
+ return ErrorKind.HOST_UNREACHABLE
155
+ if "timed out" in message or "timeout" in name:
156
+ return ErrorKind.TIMEOUT
157
+ return ErrorKind.PROTOCOL_ERROR
@@ -0,0 +1,129 @@
1
+ """Windows command execution via WinRM (pywinrm)."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ import requests.exceptions
8
+ import winrm
9
+
10
+ from cgate.executor.base import DEFAULT_TIMEOUT_SECONDS, ErrorKind, ExecutionResult
11
+
12
+ if TYPE_CHECKING:
13
+ from cgate.connections.auth import StoredCredential
14
+
15
+ WINRM_HTTP_PORT: Final = 5985
16
+ WINRM_HTTPS_PORT: Final = 5986
17
+
18
+
19
+ def _endpoint(hostname: str, *, https: bool) -> str:
20
+ port = WINRM_HTTPS_PORT if https else WINRM_HTTP_PORT
21
+ scheme = "https" if https else "http"
22
+ return f"{scheme}://{hostname}:{port}/wsman"
23
+
24
+
25
+ def execute_windows(
26
+ hostname: str,
27
+ command: str,
28
+ credential: StoredCredential,
29
+ *,
30
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
31
+ https: bool = True,
32
+ ) -> ExecutionResult:
33
+ """Run a command on a host over WinRM with NTLM authentication.
34
+
35
+ Prefers the protocol requested by the caller (defaults to HTTPS for
36
+ the secure WinRM config) but falls back to the other protocol when
37
+ the primary listener is unreachable. WinRM operators commonly run
38
+ HTTP on port 5985 for internal/lab hosts where TLS is not wired
39
+ up, so a connection refused on 5986 should not be fatal when 5985
40
+ is available.
41
+
42
+ ``server_cert_validation`` is passed explicitly (issue #7): pywinrm
43
+ already defaults to ``"validate"``, but pinning it in code makes the
44
+ guarantee explicit rather than resting on the library's default. A
45
+ certificate that fails validation is NOT treated as "try the other
46
+ protocol" like a refused/unreachable connection is -- something IS
47
+ listening and presenting an untrusted cert, so falling back to plain
48
+ HTTP there would silently downgrade a possibly-MITM'd HTTPS attempt
49
+ into an unauthenticated cleartext one instead of failing loudly.
50
+
51
+ Commands are executed via PowerShell (``session.run_ps``) rather
52
+ than the legacy cmd shell. PowerShell handles both PowerShell
53
+ cmdlets (``Get-ChildItem``, ``Set-Service``, ...) and classic
54
+ cmd-line utilities (``dir``, ``type``, ...) transparently, so this
55
+ covers the common cases without forcing users to wrap commands
56
+ manually. Per-server shell selection (or cmd-vs-ps fallback) is
57
+ deferred to a future iteration.
58
+ """
59
+ started = time.monotonic()
60
+ last_exc: Exception | None = None
61
+ for attempt_https in (https, not https):
62
+ try:
63
+ session = winrm.Session(
64
+ _endpoint(hostname, https=attempt_https),
65
+ auth=(credential.username, credential.password or ""),
66
+ transport="ntlm",
67
+ server_cert_validation="validate",
68
+ # pywinrm requires ``read_timeout_sec > operation_timeout_sec``;
69
+ # otherwise it raises ``read_timeout_sec must exceed
70
+ # operation_timeout_sec``. The operation budget covers the
71
+ # round-trip + remote execution; the read budget also has
72
+ # to cover pulling the response back, so we add a fixed
73
+ # margin.
74
+ operation_timeout_sec=timeout,
75
+ read_timeout_sec=timeout + 30,
76
+ )
77
+ response = session.run_ps(command)
78
+ return ExecutionResult(
79
+ stdout=_decode_output(response.std_out),
80
+ stderr=_decode_output(response.std_err),
81
+ exit_code=int(response.status_code),
82
+ duration_ms=int((time.monotonic() - started) * 1000),
83
+ error_kind=None,
84
+ )
85
+ except requests.exceptions.SSLError as exc:
86
+ return _failure_from_winrm_exception(exc, started)
87
+ except Exception as exc: # noqa: BLE001 - SDK errors lack a closed common hierarchy
88
+ last_exc = exc
89
+ continue
90
+
91
+ assert last_exc is not None # loop ran at least once
92
+ return _failure_from_winrm_exception(last_exc, started)
93
+
94
+
95
+ def _decode_output(output: str | bytes | bytearray | None) -> str:
96
+ if isinstance(output, (bytes, bytearray)):
97
+ return output.decode("utf-8", errors="replace")
98
+ return output or ""
99
+
100
+
101
+ def _failure_from_winrm_exception(exc: Exception, started: float) -> ExecutionResult:
102
+ return ExecutionResult(
103
+ stdout="",
104
+ stderr=str(exc),
105
+ exit_code=-1,
106
+ duration_ms=int((time.monotonic() - started) * 1000),
107
+ error_kind=_classify_winrm_exception(exc),
108
+ )
109
+
110
+
111
+ def _classify_winrm_exception(exc: Exception) -> ErrorKind:
112
+ name = type(exc).__name__.lower()
113
+ message = str(exc).lower()
114
+ if any(signal in message for signal in ("401", "unauthorized", "auth", "credential")):
115
+ return ErrorKind.AUTH_FAILED
116
+ if "timed out" in message or "timeout" in name:
117
+ return ErrorKind.TIMEOUT
118
+ if any(
119
+ signal in message
120
+ for signal in (
121
+ "connection refused",
122
+ "no route to host",
123
+ "getaddrinfo failed",
124
+ "name or service not known",
125
+ "network is unreachable",
126
+ )
127
+ ):
128
+ return ErrorKind.HOST_UNREACHABLE
129
+ return ErrorKind.PROTOCOL_ERROR
@@ -0,0 +1,10 @@
1
+ """Standalone Windows file-swap helper (``cgate-helper.exe``).
2
+
3
+ Built as its own PyInstaller binary, separate from the main ``cgate.exe``,
4
+ so it never shares an image name with the process it's replacing/deleting
5
+ and there is nothing to disambiguate on Windows's process list. Must not
6
+ import anything from ``cgate.cli`` -- keep this package's dependency graph
7
+ to the standard library plus ``cgate.core`` only.
8
+ """
9
+
10
+ from __future__ import annotations
@@ -0,0 +1,112 @@
1
+ """Entry point for the standalone ``cgate-helper.exe`` binary.
2
+
3
+ Fully unattended: never prompts, always exits with a code and a log line.
4
+ Invoked by the main ``cgate.exe`` (see ``cgate.update.spawn_helper``) after
5
+ it has decided a self-lock swap/delete needs to happen from a process that
6
+ isn't ``cgate.exe`` itself.
7
+
8
+ Usage:
9
+ cgate-helper.exe replace --target PATH --source PATH --wait-pid N [...]
10
+ cgate-helper.exe delete --target PATH --wait-pid N [...]
11
+ cgate-helper.exe heal --target PATH [--wait-pid N ...]
12
+
13
+ ``heal`` auto-detects ``<target>.new`` and atomically swaps it onto
14
+ ``<target>`` -- the self-healing path used to finish a previously failed
15
+ ``update apply`` when the next ``cgate`` invocation notices the staged
16
+ file and no other cgate processes are running.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from cgate.core.update_log import append_log
26
+ from cgate.helper.waiter import (
27
+ SOURCE_NAMES,
28
+ is_safe_target,
29
+ retry_delete,
30
+ retry_replace,
31
+ wait_for_pids,
32
+ )
33
+
34
+ _WAIT_TIMEOUT_SECONDS = 30.0
35
+ _RETRY_TOTAL_SECONDS = 10.0
36
+
37
+
38
+ def _helper_dir() -> Path:
39
+ """Return the directory this helper (or its source, when unfrozen) lives in."""
40
+ if getattr(sys, "frozen", False):
41
+ return Path(sys.executable).resolve().parent
42
+ return Path(__file__).resolve().parent
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ """Build the ``replace``/``delete`` subcommand parser."""
47
+ parser = argparse.ArgumentParser(prog="cgate-helper")
48
+ subparsers = parser.add_subparsers(dest="operation", required=True)
49
+
50
+ replace = subparsers.add_parser("replace", help="Move --source onto --target.")
51
+ replace.add_argument("--target", required=True, type=Path)
52
+ replace.add_argument("--source", required=True, type=Path)
53
+ replace.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
54
+
55
+ delete = subparsers.add_parser("delete", help="Delete --target.")
56
+ delete.add_argument("--target", required=True, type=Path)
57
+ delete.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
58
+
59
+ heal = subparsers.add_parser(
60
+ "heal",
61
+ help="If <target>.new exists, atomically swap it onto <target>.",
62
+ )
63
+ heal.add_argument("--target", required=True, type=Path)
64
+ heal.add_argument("--wait-pid", type=int, action="append", default=[], dest="wait_pids")
65
+
66
+ return parser
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ """Run one wait-then-replace/delete operation. Never prompts."""
71
+ args = build_parser().parse_args(argv)
72
+ helper_dir = _helper_dir()
73
+
74
+ if not is_safe_target(args.target, helper_dir=helper_dir):
75
+ append_log(f"helper {args.operation}: refusing unsafe target {args.target}")
76
+ return 3
77
+ if args.operation == "replace" and not is_safe_target(
78
+ args.source, helper_dir=helper_dir, allowed_names=SOURCE_NAMES
79
+ ):
80
+ append_log(f"helper replace: refusing unsafe source {args.source}")
81
+ return 3
82
+
83
+ if args.wait_pids and not wait_for_pids(args.wait_pids, timeout=_WAIT_TIMEOUT_SECONDS):
84
+ append_log(
85
+ f"helper {args.operation}: timed out after {_WAIT_TIMEOUT_SECONDS:g}s waiting for "
86
+ f"PID(s) {args.wait_pids} to exit; proceeding anyway"
87
+ )
88
+
89
+ if args.operation == "replace":
90
+ error = retry_replace(args.source, args.target, total_seconds=_RETRY_TOTAL_SECONDS)
91
+ elif args.operation == "delete":
92
+ error = retry_delete(args.target, total_seconds=_RETRY_TOTAL_SECONDS)
93
+ else:
94
+ staging = args.target.with_name(args.target.name + ".new")
95
+ if not staging.exists():
96
+ append_log(f"helper heal: no pending update at {staging}")
97
+ return 0
98
+ if not is_safe_target(staging, helper_dir=helper_dir, allowed_names=SOURCE_NAMES):
99
+ append_log(f"helper heal: refusing unsafe staging {staging}")
100
+ return 3
101
+ error = retry_replace(staging, args.target, total_seconds=_RETRY_TOTAL_SECONDS)
102
+
103
+ if error is not None:
104
+ append_log(f"helper {args.operation} failed for {args.target}: {error}")
105
+ return 2
106
+
107
+ append_log(f"helper {args.operation} succeeded for {args.target}")
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main())