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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""TCP probe to detect whether a remote host is Windows (WinRM) or Linux (SSH).
|
|
2
|
+
|
|
3
|
+
Pure logic, no DB access, no keyring. Tests can mock socket.create_connection.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import socket
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from cgate.db.types import ServerType
|
|
11
|
+
|
|
12
|
+
SSH_PORT = 22
|
|
13
|
+
WINRM_HTTP_PORT = 5985
|
|
14
|
+
WINRM_HTTPS_PORT = 5986
|
|
15
|
+
DEFAULT_PROBE_TIMEOUT = 2.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AmbiguousHostError(Exception):
|
|
19
|
+
"""Host responded to BOTH SSH and WinRM; user must choose."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnknownHostError(Exception):
|
|
23
|
+
"""Host did not respond to either SSH or WinRM."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _probe(hostname: str, port: int, timeout: float) -> bool:
|
|
27
|
+
"""Try TCP connect and report whether the port is open within the timeout."""
|
|
28
|
+
try:
|
|
29
|
+
with socket.create_connection((hostname, port), timeout=timeout):
|
|
30
|
+
return True
|
|
31
|
+
except OSError:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class DetectionProbe:
|
|
37
|
+
"""Result of probing a host for SSH and WinRM."""
|
|
38
|
+
|
|
39
|
+
hostname: str
|
|
40
|
+
ssh: bool
|
|
41
|
+
winrm: bool
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def server_type(self) -> ServerType:
|
|
45
|
+
"""Resolve the result or raise when the host is ambiguous or unknown."""
|
|
46
|
+
if self.ssh and self.winrm:
|
|
47
|
+
message = " ".join(
|
|
48
|
+
(
|
|
49
|
+
f"{self.hostname} responded to both SSH (22) and WinRM",
|
|
50
|
+
"(5985/5986). Specify which to use.",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
raise AmbiguousHostError(message)
|
|
54
|
+
if self.ssh:
|
|
55
|
+
return ServerType.LINUX
|
|
56
|
+
if self.winrm:
|
|
57
|
+
return ServerType.WINDOWS
|
|
58
|
+
message = " ".join(
|
|
59
|
+
(
|
|
60
|
+
f"{self.hostname} did not respond to SSH (22) or WinRM",
|
|
61
|
+
"(5985/5986).",
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
raise UnknownHostError(message)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def probe_host(
|
|
68
|
+
hostname: str, *, timeout: float = DEFAULT_PROBE_TIMEOUT
|
|
69
|
+
) -> DetectionProbe:
|
|
70
|
+
"""Probe the host for SSH and WinRM and return both binary signals."""
|
|
71
|
+
ssh = _probe(hostname, SSH_PORT, timeout)
|
|
72
|
+
winrm_https = _probe(hostname, WINRM_HTTPS_PORT, timeout)
|
|
73
|
+
winrm_http = _probe(hostname, WINRM_HTTP_PORT, timeout)
|
|
74
|
+
return DetectionProbe(
|
|
75
|
+
hostname=hostname,
|
|
76
|
+
ssh=ssh,
|
|
77
|
+
winrm=winrm_https or winrm_http,
|
|
78
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Persistence for saved connection aliases — parallels detect.py and auth.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from cgate.db.connection import Database, connect
|
|
7
|
+
from cgate.db.rows import iso, row_to_connection
|
|
8
|
+
from cgate.db.types import Connection, ServerType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConnectionsRepo:
|
|
12
|
+
"""Saved connection CRUD keyed by a user-defined alias."""
|
|
13
|
+
|
|
14
|
+
_db: Database # class-level annotation required by strict mode
|
|
15
|
+
|
|
16
|
+
def __init__(self, db: Database) -> None:
|
|
17
|
+
"""Store the database handle for subsequent operations."""
|
|
18
|
+
self._db = db
|
|
19
|
+
|
|
20
|
+
def add(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
alias: str,
|
|
24
|
+
hostname: str,
|
|
25
|
+
server_type: ServerType,
|
|
26
|
+
detection_ssh: bool,
|
|
27
|
+
detection_winrm: bool,
|
|
28
|
+
) -> Connection:
|
|
29
|
+
"""Save a connection with its detection signals and current UTC timestamp."""
|
|
30
|
+
now = datetime.now(UTC)
|
|
31
|
+
with connect(self._db) as conn:
|
|
32
|
+
_ = conn.execute(
|
|
33
|
+
"""
|
|
34
|
+
INSERT INTO connections (alias, hostname, server_type,
|
|
35
|
+
detection_ssh, detection_winrm, created_at)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
37
|
+
""",
|
|
38
|
+
(
|
|
39
|
+
alias,
|
|
40
|
+
hostname,
|
|
41
|
+
server_type.value,
|
|
42
|
+
detection_ssh,
|
|
43
|
+
detection_winrm,
|
|
44
|
+
iso(now),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
return Connection(
|
|
48
|
+
alias=alias,
|
|
49
|
+
hostname=hostname,
|
|
50
|
+
server_type=server_type,
|
|
51
|
+
detection_ssh=detection_ssh,
|
|
52
|
+
detection_winrm=detection_winrm,
|
|
53
|
+
created_at=now,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def get(self, alias: str) -> Connection | None:
|
|
57
|
+
"""Fetch a connection by alias, or None if it is absent."""
|
|
58
|
+
with connect(self._db) as conn:
|
|
59
|
+
row = conn.execute(
|
|
60
|
+
"""
|
|
61
|
+
SELECT alias, hostname, server_type, detection_ssh, detection_winrm,
|
|
62
|
+
created_at
|
|
63
|
+
FROM connections WHERE alias = ?
|
|
64
|
+
""",
|
|
65
|
+
(alias,),
|
|
66
|
+
).fetchone()
|
|
67
|
+
return row_to_connection(row) if row is not None else None
|
|
68
|
+
|
|
69
|
+
def list_all(self) -> list[Connection]:
|
|
70
|
+
"""Return every saved connection ordered by alias."""
|
|
71
|
+
with connect(self._db) as conn:
|
|
72
|
+
rows = conn.execute(
|
|
73
|
+
"""
|
|
74
|
+
SELECT alias, hostname, server_type, detection_ssh, detection_winrm,
|
|
75
|
+
created_at
|
|
76
|
+
FROM connections ORDER BY alias ASC
|
|
77
|
+
"""
|
|
78
|
+
).fetchall()
|
|
79
|
+
return [row_to_connection(row) for row in rows]
|
|
80
|
+
|
|
81
|
+
def remove(self, alias: str) -> bool:
|
|
82
|
+
"""Delete a connection and report whether a row was removed."""
|
|
83
|
+
with connect(self._db) as conn:
|
|
84
|
+
cursor = conn.execute(
|
|
85
|
+
"DELETE FROM connections WHERE alias = ?",
|
|
86
|
+
(alias,),
|
|
87
|
+
)
|
|
88
|
+
return cursor.rowcount > 0
|
cgate/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
cgate/core/path_env.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
r"""Per-user install directory and PATH registration mechanics.
|
|
2
|
+
|
|
3
|
+
Pure logic, no typer/rich -- testable without CliRunner. On Windows, every
|
|
4
|
+
registry call goes through the module-level ``winreg`` name (never a
|
|
5
|
+
function-local ``import winreg``) specifically so tests can replace it
|
|
6
|
+
with a fake and never touch the real ``HKEY_CURRENT_USER\Environment``
|
|
7
|
+
key on the machine running the test suite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Final
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import winreg
|
|
21
|
+
except ImportError: # non-Windows -- module must still import cleanly
|
|
22
|
+
winreg = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
_ENV_SUBKEY: Final = "Environment"
|
|
25
|
+
_PATH_VALUE_NAME: Final = "Path"
|
|
26
|
+
_MARKER: Final = "# Added by cgate install"
|
|
27
|
+
|
|
28
|
+
# WM_SETTINGCHANGE broadcast constants (winuser.h).
|
|
29
|
+
_HWND_BROADCAST: Final = 0xFFFF
|
|
30
|
+
_WM_SETTINGCHANGE: Final = 0x001A
|
|
31
|
+
_SMTO_ABORTIFHUNG: Final = 0x0002
|
|
32
|
+
_BROADCAST_TIMEOUT_MS: Final = 5000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install_dir() -> Path:
|
|
36
|
+
r"""Per-user directory ``cgate install`` copies the binary into.
|
|
37
|
+
|
|
38
|
+
Windows: ``%USERPROFILE%\bin`` (already the directory named in
|
|
39
|
+
TECHNICAL.md's manual instructions). macOS/Linux: ``~/.local/bin``.
|
|
40
|
+
No admin/sudo path exists or is planned -- user-level only.
|
|
41
|
+
"""
|
|
42
|
+
if sys.platform == "win32":
|
|
43
|
+
return Path.home() / "bin"
|
|
44
|
+
return Path.home() / ".local" / "bin"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_target_path() -> Path:
|
|
48
|
+
"""Where the installed binary itself should live.
|
|
49
|
+
|
|
50
|
+
A fixed filename ("cgate.exe" / "cgate"), not whatever the source
|
|
51
|
+
file happens to be named (e.g. ``cgate-windows-amd64.exe`` as
|
|
52
|
+
downloaded), so `cgate` on PATH resolves regardless of the download's
|
|
53
|
+
original name.
|
|
54
|
+
"""
|
|
55
|
+
name = "cgate.exe" if sys.platform == "win32" else "cgate"
|
|
56
|
+
return install_dir() / name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def copy_binary(source: Path, target: Path) -> None:
|
|
60
|
+
"""Copy ``source`` to ``target``, preserving the executable bit.
|
|
61
|
+
|
|
62
|
+
Uses ``shutil.copy2`` -- NOT ``copyfile``, which does not preserve
|
|
63
|
+
permission bits and would silently produce a non-executable copy on
|
|
64
|
+
macOS/Linux.
|
|
65
|
+
"""
|
|
66
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
shutil.copy2(source, target)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- Windows: HKCU\Environment\Path ----------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ensure_windows_user_path(directory: Path) -> str:
|
|
74
|
+
r"""Ensure ``directory`` is present in ``HKCU\Environment\Path``.
|
|
75
|
+
|
|
76
|
+
Returns ``"already_present"`` or ``"added"``. Raises ``OSError`` on a
|
|
77
|
+
genuine registry failure. Windows-only -- the function has no hive
|
|
78
|
+
parameter, so it is structurally impossible for it to target
|
|
79
|
+
anything but ``HKEY_CURRENT_USER``; ``HKEY_LOCAL_MACHINE`` (which
|
|
80
|
+
would require admin rights) is never referenced anywhere in this
|
|
81
|
+
module.
|
|
82
|
+
"""
|
|
83
|
+
assert winreg is not None, "ensure_windows_user_path must only be called on win32"
|
|
84
|
+
target = str(directory)
|
|
85
|
+
|
|
86
|
+
# CreateKeyEx (not OpenKey) so a brand-new user profile where the
|
|
87
|
+
# Environment key itself doesn't exist yet still works -- it opens or
|
|
88
|
+
# creates atomically instead of needing a separate existence check.
|
|
89
|
+
with winreg.CreateKeyEx(
|
|
90
|
+
winreg.HKEY_CURRENT_USER, _ENV_SUBKEY, 0, winreg.KEY_READ | winreg.KEY_WRITE
|
|
91
|
+
) as key:
|
|
92
|
+
try:
|
|
93
|
+
current_value, value_type = winreg.QueryValueEx(key, _PATH_VALUE_NAME)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
# Rare but real: brand-new profile, Path value never created.
|
|
96
|
+
# REG_EXPAND_SZ is the type Windows itself uses for a
|
|
97
|
+
# freshly-created user Path value.
|
|
98
|
+
current_value, value_type = "", winreg.REG_EXPAND_SZ
|
|
99
|
+
|
|
100
|
+
if _path_dir_present(current_value, target):
|
|
101
|
+
return "already_present"
|
|
102
|
+
|
|
103
|
+
new_value = f"{current_value};{target}" if current_value else target
|
|
104
|
+
# value_type is exactly what was read above (or the default) --
|
|
105
|
+
# never assumed, never upgraded. We only ever append a
|
|
106
|
+
# fully-resolved literal directory (no "%...%" tokens of our
|
|
107
|
+
# own), so appending into a REG_EXPAND_SZ value is always safe.
|
|
108
|
+
winreg.SetValueEx(key, _PATH_VALUE_NAME, 0, value_type, new_value)
|
|
109
|
+
|
|
110
|
+
_broadcast_environment_change()
|
|
111
|
+
return "added"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _path_dir_present(current_value: str, target: str) -> bool:
|
|
115
|
+
"""Case-insensitive, ``;``-split, ``%VAR%``-tolerant membership check.
|
|
116
|
+
|
|
117
|
+
Deliberately does not call ``Path.resolve()`` (which touches the
|
|
118
|
+
filesystem and requires the path to exist) -- pure string
|
|
119
|
+
normalization, so it works identically whether or not the directory
|
|
120
|
+
exists yet.
|
|
121
|
+
"""
|
|
122
|
+
target_norm = os.path.normcase(os.path.normpath(target))
|
|
123
|
+
for raw_entry in current_value.split(";"):
|
|
124
|
+
entry = raw_entry.strip()
|
|
125
|
+
if not entry:
|
|
126
|
+
continue
|
|
127
|
+
expanded = os.path.expandvars(entry) # handles %USERPROFILE%\bin etc.
|
|
128
|
+
if os.path.normcase(os.path.normpath(expanded)) == target_norm:
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _broadcast_environment_change() -> None:
|
|
134
|
+
"""Best-effort WM_SETTINGCHANGE broadcast.
|
|
135
|
+
|
|
136
|
+
Lets newly-launched processes pick up the PATH change without a full
|
|
137
|
+
logoff (already-open shells still won't see it). Must never be
|
|
138
|
+
fatal -- wrapped broadly and always swallowed.
|
|
139
|
+
|
|
140
|
+
``argtypes``/``restype`` are declared explicitly and are NOT
|
|
141
|
+
optional: without them ctypes defaults every argument to 32-bit
|
|
142
|
+
``c_int`` on a 64-bit process, corrupting the HWND, the lParam
|
|
143
|
+
string pointer, and the output pointer -- silent memory corruption,
|
|
144
|
+
not just "might not work". The output parameter is ``DWORD_PTR``
|
|
145
|
+
(pointer-sized, 8 bytes on 64-bit Windows), so it uses ``c_size_t``,
|
|
146
|
+
not ``c_ulong`` (which stays 32-bit under Windows' LLP64 model and
|
|
147
|
+
would be undersized for the write).
|
|
148
|
+
"""
|
|
149
|
+
if sys.platform != "win32":
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
|
|
153
|
+
send = user32.SendMessageTimeoutW
|
|
154
|
+
send.argtypes = [
|
|
155
|
+
ctypes.c_void_p, # HWND
|
|
156
|
+
ctypes.c_uint, # UINT Msg
|
|
157
|
+
ctypes.c_void_p, # WPARAM
|
|
158
|
+
ctypes.c_wchar_p, # LPARAM ("Environment")
|
|
159
|
+
ctypes.c_uint, # UINT fuFlags
|
|
160
|
+
ctypes.c_uint, # UINT uTimeout
|
|
161
|
+
ctypes.POINTER(ctypes.c_size_t), # PDWORD_PTR lpdwResult
|
|
162
|
+
]
|
|
163
|
+
send.restype = ctypes.c_ssize_t # LRESULT
|
|
164
|
+
result = ctypes.c_size_t()
|
|
165
|
+
send(
|
|
166
|
+
ctypes.c_void_p(_HWND_BROADCAST),
|
|
167
|
+
_WM_SETTINGCHANGE,
|
|
168
|
+
None,
|
|
169
|
+
"Environment",
|
|
170
|
+
_SMTO_ABORTIFHUNG,
|
|
171
|
+
_BROADCAST_TIMEOUT_MS,
|
|
172
|
+
ctypes.byref(result),
|
|
173
|
+
)
|
|
174
|
+
except (OSError, AttributeError, TypeError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---- POSIX: shell rc file ---------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def detect_shell_rc() -> Path:
|
|
182
|
+
"""Best-effort ``$SHELL``-based rc file detection.
|
|
183
|
+
|
|
184
|
+
A heuristic, not a guarantee: ``$SHELL`` reflects the login shell,
|
|
185
|
+
not necessarily whatever shell actually launched ``cgate install``,
|
|
186
|
+
and classic macOS bash setups that source ``.bash_profile`` instead
|
|
187
|
+
of ``.bashrc`` won't be picked up. Falls back to ``~/.profile`` (a
|
|
188
|
+
portable POSIX default) for anything unrecognized.
|
|
189
|
+
"""
|
|
190
|
+
name = Path(os.environ.get("SHELL", "")).name
|
|
191
|
+
home = Path.home()
|
|
192
|
+
if name == "zsh":
|
|
193
|
+
return home / ".zshrc"
|
|
194
|
+
if name == "fish":
|
|
195
|
+
return home / ".config" / "fish" / "config.fish"
|
|
196
|
+
if name == "bash":
|
|
197
|
+
return home / ".bashrc"
|
|
198
|
+
return home / ".profile"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def ensure_posix_shell_path(directory: Path, rc_path: Path | None = None) -> tuple[str, Path]:
|
|
202
|
+
"""Ensure ``directory`` is exported on PATH via a shell rc file.
|
|
203
|
+
|
|
204
|
+
Idempotent via ``_MARKER``. ``rc_path`` is injectable so tests never
|
|
205
|
+
touch a real home-directory dotfile. Returns ``(status, rc_path)``
|
|
206
|
+
where status is ``"already_present"`` or ``"added"``. Cannot affect
|
|
207
|
+
the currently-running parent shell -- the caller must tell the user
|
|
208
|
+
to restart their terminal or ``source`` the file.
|
|
209
|
+
"""
|
|
210
|
+
rc_path = rc_path if rc_path is not None else detect_shell_rc()
|
|
211
|
+
existing = rc_path.read_text(encoding="utf-8") if rc_path.exists() else ""
|
|
212
|
+
if _MARKER in existing:
|
|
213
|
+
return "already_present", rc_path
|
|
214
|
+
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
block = f'\n{_MARKER}\nexport PATH="{directory}:$PATH"\n'
|
|
216
|
+
with rc_path.open("a", encoding="utf-8") as f:
|
|
217
|
+
f.write(block)
|
|
218
|
+
return "added", rc_path
|
cgate/core/paths.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Per-user storage paths for command-gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def data_dir() -> Path:
|
|
11
|
+
"""Return the OS-appropriate per-user data directory for command-gate."""
|
|
12
|
+
app = "command-gate"
|
|
13
|
+
if os.name == "nt":
|
|
14
|
+
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA")
|
|
15
|
+
if base is None:
|
|
16
|
+
base = str(Path.home() / "AppData" / "Local")
|
|
17
|
+
return Path(base) / app
|
|
18
|
+
if sys.platform == "darwin":
|
|
19
|
+
return Path.home() / "Library" / "Application Support" / app
|
|
20
|
+
base = os.environ.get("XDG_DATA_HOME")
|
|
21
|
+
if base:
|
|
22
|
+
return Path(base) / app
|
|
23
|
+
return Path.home() / ".local" / "share" / app
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def db_path() -> Path:
|
|
27
|
+
"""Return the path to the SQLite database.
|
|
28
|
+
|
|
29
|
+
Overridable via the CGATE_DB_PATH environment variable (used by tests
|
|
30
|
+
and by operators who want to point at an alternate location).
|
|
31
|
+
"""
|
|
32
|
+
override = os.environ.get("CGATE_DB_PATH")
|
|
33
|
+
if override:
|
|
34
|
+
return Path(override)
|
|
35
|
+
return data_dir() / "cgate.db"
|
cgate/core/update_log.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Best-effort append-only logging for background update/uninstall operations.
|
|
2
|
+
|
|
3
|
+
Lives in ``cgate.core`` (not ``cgate.cli``) because it's shared by the
|
|
4
|
+
interactive CLI and the non-interactive ``cgate.helper`` binary, which must
|
|
5
|
+
not import anything from ``cgate.cli`` (typer/rich/mcp/paramiko/pywinrm).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
import cgate.core.paths
|
|
13
|
+
|
|
14
|
+
_LOG_FILENAME = "update.log"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def append_log(message: str) -> None:
|
|
18
|
+
"""Append a timestamped line to ``<data_dir>/update.log``. Never raises.
|
|
19
|
+
|
|
20
|
+
Best-effort: any failure (permission denied, missing dir, encoding
|
|
21
|
+
errors) is silently swallowed. Logging must never crash the caller --
|
|
22
|
+
in particular, the helper process that runs after the main cgate
|
|
23
|
+
process has exited has no other way to surface failure.
|
|
24
|
+
|
|
25
|
+
Uses ``cgate.core.paths.data_dir`` (attribute lookup at call time)
|
|
26
|
+
rather than a module-level import so tests can monkeypatch the data
|
|
27
|
+
directory location.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
log_path = cgate.core.paths.data_dir() / _LOG_FILENAME
|
|
31
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
timestamp = datetime.now(UTC).isoformat(timespec="seconds")
|
|
33
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
34
|
+
f.write(f"[{timestamp}] {message}\n")
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
cgate/db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
cgate/db/batches.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""BatchesRepo: CRUD + FIFO queue queries for the `batches` table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from cgate.db.connection import Database, connect
|
|
8
|
+
from cgate.db.rows import iso, row_to_batch
|
|
9
|
+
from cgate.db.types import Batch, BatchId
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BatchesRepo:
|
|
13
|
+
"""Batch CRUD + queue queries (FIFO pending list, mark resolved)."""
|
|
14
|
+
|
|
15
|
+
_db: Database # class-level annotation required by strict mode
|
|
16
|
+
|
|
17
|
+
def __init__(self, db: Database) -> None:
|
|
18
|
+
"""Store the database handle for subsequent operations."""
|
|
19
|
+
self._db = db
|
|
20
|
+
|
|
21
|
+
def create(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
title: str,
|
|
25
|
+
description: str | None,
|
|
26
|
+
requested_by_agent: str | None,
|
|
27
|
+
) -> Batch:
|
|
28
|
+
"""Create a new batch with a fresh UUID and the current UTC timestamp."""
|
|
29
|
+
batch_id = BatchId(str(uuid4()))
|
|
30
|
+
now = datetime.now(UTC)
|
|
31
|
+
with connect(self._db) as conn:
|
|
32
|
+
_ = conn.execute(
|
|
33
|
+
"""
|
|
34
|
+
INSERT INTO batches
|
|
35
|
+
(id, title, description, requested_by_agent, created_at)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?)
|
|
37
|
+
""",
|
|
38
|
+
(batch_id, title, description, requested_by_agent, iso(now)),
|
|
39
|
+
)
|
|
40
|
+
return Batch(
|
|
41
|
+
id=batch_id,
|
|
42
|
+
title=title,
|
|
43
|
+
description=description,
|
|
44
|
+
requested_by_agent=requested_by_agent,
|
|
45
|
+
created_at=now,
|
|
46
|
+
resolved_at=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def get(self, batch_id: BatchId) -> Batch | None:
|
|
50
|
+
"""Fetch a batch by its ID, or None if not found."""
|
|
51
|
+
with connect(self._db) as conn:
|
|
52
|
+
row = conn.execute(
|
|
53
|
+
"""
|
|
54
|
+
SELECT id, title, description, requested_by_agent, created_at, resolved_at
|
|
55
|
+
FROM batches WHERE id = ?
|
|
56
|
+
""",
|
|
57
|
+
(batch_id,),
|
|
58
|
+
).fetchone()
|
|
59
|
+
return row_to_batch(row) if row is not None else None
|
|
60
|
+
|
|
61
|
+
def list_pending(self) -> list[Batch]:
|
|
62
|
+
"""Return pending batches (resolved_at IS NULL), oldest first (FIFO)."""
|
|
63
|
+
with connect(self._db) as conn:
|
|
64
|
+
rows = conn.execute(
|
|
65
|
+
"""
|
|
66
|
+
SELECT id, title, description, requested_by_agent, created_at, resolved_at
|
|
67
|
+
FROM batches
|
|
68
|
+
WHERE resolved_at IS NULL
|
|
69
|
+
ORDER BY created_at ASC, id ASC
|
|
70
|
+
"""
|
|
71
|
+
).fetchall()
|
|
72
|
+
return [row_to_batch(r) for r in rows]
|
|
73
|
+
|
|
74
|
+
def list_resolved(self, *, limit: int | None = 50) -> list[Batch]:
|
|
75
|
+
"""Return resolved batches, most recently resolved first.
|
|
76
|
+
|
|
77
|
+
The live queue drops a batch the instant it resolves (`list_pending`
|
|
78
|
+
filters it out), so this is the only way to look back at anything
|
|
79
|
+
that already went through -- approved, rejected, or auto-executed.
|
|
80
|
+
|
|
81
|
+
``limit=None`` returns every resolved batch (SQLite's own
|
|
82
|
+
``LIMIT -1`` means "unlimited") -- used by ``cgate history export``,
|
|
83
|
+
where the whole audit trail is the point, unlike the History
|
|
84
|
+
screen's bounded browse list.
|
|
85
|
+
"""
|
|
86
|
+
with connect(self._db) as conn:
|
|
87
|
+
rows = conn.execute(
|
|
88
|
+
"""
|
|
89
|
+
SELECT id, title, description, requested_by_agent, created_at, resolved_at
|
|
90
|
+
FROM batches
|
|
91
|
+
WHERE resolved_at IS NOT NULL
|
|
92
|
+
ORDER BY resolved_at DESC
|
|
93
|
+
LIMIT ?
|
|
94
|
+
""",
|
|
95
|
+
(limit if limit is not None else -1,),
|
|
96
|
+
).fetchall()
|
|
97
|
+
return [row_to_batch(r) for r in rows]
|
|
98
|
+
|
|
99
|
+
def mark_resolved(
|
|
100
|
+
self, batch_id: BatchId, *, resolved_at: datetime | None = None
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Stamp resolved_at; no-op if the batch is already resolved."""
|
|
103
|
+
when = resolved_at if resolved_at is not None else datetime.now(UTC)
|
|
104
|
+
with connect(self._db) as conn:
|
|
105
|
+
_ = conn.execute(
|
|
106
|
+
"""
|
|
107
|
+
UPDATE batches SET resolved_at = ?
|
|
108
|
+
WHERE id = ? AND resolved_at IS NULL
|
|
109
|
+
""",
|
|
110
|
+
(iso(when), batch_id),
|
|
111
|
+
)
|