Add native Windows compatibility layer
This commit is contained in:
@@ -26,7 +26,7 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
||||
"""
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w") as f:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=indent)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
@@ -36,7 +36,7 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
|
||||
def atomic_write_text(path: str, text: str) -> None:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w") as f:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
@@ -68,7 +68,7 @@ class AuthManager:
|
||||
def _load(self):
|
||||
try:
|
||||
if os.path.exists(self.auth_path):
|
||||
with open(self.auth_path, "r") as f:
|
||||
with open(self.auth_path, "r", encoding="utf-8") as f:
|
||||
self._config = json.load(f)
|
||||
logger.info("Auth config loaded")
|
||||
else:
|
||||
@@ -82,7 +82,7 @@ class AuthManager:
|
||||
"""Load persisted session tokens from disk, pruning expired ones."""
|
||||
try:
|
||||
if os.path.exists(self._sessions_path):
|
||||
with open(self._sessions_path, "r") as f:
|
||||
with open(self._sessions_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
now = time.time()
|
||||
self._sessions = {k: v for k, v in data.items() if v.get("expiry", 0) > now}
|
||||
|
||||
@@ -996,7 +996,7 @@ def _migrate_assign_legacy_owner():
|
||||
auth_path = os.path.join("data", "auth.json")
|
||||
admin_user = None
|
||||
try:
|
||||
with open(auth_path, "r") as f:
|
||||
with open(auth_path, "r", encoding="utf-8") as f:
|
||||
auth_data = _json.load(f)
|
||||
users = auth_data.get("users", {})
|
||||
if users:
|
||||
@@ -1067,12 +1067,12 @@ def _migrate_assign_legacy_owner():
|
||||
prefs_path = os.path.join("data", "user_prefs.json")
|
||||
try:
|
||||
if os.path.exists(prefs_path):
|
||||
with open(prefs_path, "r") as f:
|
||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||
prefs = _json.load(f)
|
||||
if "_users" not in prefs and prefs:
|
||||
# Flat format → nest under admin user
|
||||
new_prefs = {"_users": {admin_user: prefs}}
|
||||
with open(prefs_path, "w") as f:
|
||||
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||
_json.dump(new_prefs, f, indent=2)
|
||||
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
||||
except Exception as e:
|
||||
@@ -1437,7 +1437,7 @@ def _migrate_seed_email_account():
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
s = _json.loads(settings_file.read_text())
|
||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
203
core/platform_compat.py
Normal file
203
core/platform_compat.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Cross-platform OS compatibility helpers.
|
||||
|
||||
Odysseus began as a Linux/macOS/Docker-only app. This module centralizes the
|
||||
small set of OS differences needed to run it *natively* on Windows so the rest
|
||||
of the codebase can stay platform-agnostic. Import from here instead of
|
||||
sprinkling ``os.name == "nt"`` checks (and POSIX-only calls) across modules.
|
||||
|
||||
Design rules:
|
||||
* Stdlib + ctypes only — no new third-party deps (no psutil/pywinpty).
|
||||
* POSIX behaviour is unchanged; Windows gets a faithful equivalent or a
|
||||
safe, documented no-op.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
IS_POSIX = not IS_WINDOWS
|
||||
|
||||
|
||||
# ── File permissions ────────────────────────────────────────────────────────
|
||||
def safe_chmod(path, mode: int) -> bool:
|
||||
"""``os.chmod`` that is a harmless no-op on Windows.
|
||||
|
||||
On POSIX we apply the mode — used to lock secret/key files down to 0o600.
|
||||
Windows has no POSIX permission bits; files under the user profile are
|
||||
already ACL-restricted to that user, so we skip rather than raise. Returns
|
||||
True when the mode was actually applied.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
return False
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
# ── Process detach / liveness / teardown ────────────────────────────────────
|
||||
def detached_popen_kwargs() -> dict:
|
||||
"""Keyword args for :class:`subprocess.Popen` that fully detach a child so
|
||||
it outlives the request/stream that launched it.
|
||||
|
||||
POSIX: ``start_new_session=True`` (setsid) — new session + process group.
|
||||
Windows: ``CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`` — the child gets
|
||||
its own process group (so it isn't killed when the parent's console closes)
|
||||
and is detached from any console.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
flags = (
|
||||
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||
)
|
||||
return {"creationflags": flags}
|
||||
return {"start_new_session": True}
|
||||
|
||||
|
||||
def pid_alive(pid: Optional[int]) -> bool:
|
||||
"""True if a process with ``pid`` is currently running.
|
||||
|
||||
POSIX uses the classic ``os.kill(pid, 0)`` probe. That is **unsafe on
|
||||
Windows**: CPython's ``os.kill`` calls ``TerminateProcess(handle, sig)`` for
|
||||
any signal other than CTRL_C/CTRL_BREAK, so ``os.kill(pid, 0)`` would *kill*
|
||||
the process it is checking. We instead open the process and read its exit
|
||||
code via the Win32 API.
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
if IS_WINDOWS:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
STILL_ACTIVE = 259
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.OpenProcess(
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)
|
||||
)
|
||||
if not handle:
|
||||
return False
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return code.value == STILL_ACTIVE
|
||||
return False
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
|
||||
def kill_process_tree(pid: Optional[int]) -> None:
|
||||
"""Terminate ``pid`` and all of its descendants.
|
||||
|
||||
POSIX: signal the whole process group (``killpg``), falling back to a plain
|
||||
``kill`` if the pid isn't a group leader.
|
||||
Windows: ``taskkill /T /F`` walks and kills the child tree (there is no
|
||||
process-group signalling).
|
||||
"""
|
||||
if not pid:
|
||||
return
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
import signal
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Shell / executable resolution ───────────────────────────────────────────
|
||||
_BASH_CACHE: Optional[str] = None
|
||||
_BASH_PROBED = False
|
||||
|
||||
# Common Git-for-Windows install locations to probe when bash isn't on PATH.
|
||||
_WINDOWS_BASH_FALLBACKS = (
|
||||
r"C:\Program Files\Git\bin\bash.exe",
|
||||
r"C:\Program Files\Git\usr\bin\bash.exe",
|
||||
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
||||
)
|
||||
|
||||
|
||||
def find_bash() -> Optional[str]:
|
||||
"""Locate a real ``bash`` interpreter, or None.
|
||||
|
||||
On Windows this is typically Git Bash / WSL. Many Odysseus features (the
|
||||
agent ``bash`` tool, background jobs, Cookbook scripts) emit bash syntax, so
|
||||
when a bash is present we use it and keep full parity with POSIX. Result is
|
||||
cached.
|
||||
"""
|
||||
global _BASH_CACHE, _BASH_PROBED
|
||||
if _BASH_PROBED:
|
||||
return _BASH_CACHE
|
||||
_BASH_PROBED = True
|
||||
found = shutil.which("bash")
|
||||
if not found and IS_WINDOWS:
|
||||
for cand in _WINDOWS_BASH_FALLBACKS:
|
||||
if os.path.exists(cand):
|
||||
found = cand
|
||||
break
|
||||
_BASH_CACHE = found
|
||||
return found
|
||||
|
||||
|
||||
def has_bash() -> bool:
|
||||
return find_bash() is not None
|
||||
|
||||
|
||||
def which_tool(name: str) -> Optional[str]:
|
||||
"""``shutil.which`` that also tries Windows executable suffixes.
|
||||
|
||||
On Windows, Node/npm shims are ``npx.cmd``/``npm.cmd`` and binaries end in
|
||||
``.exe``; a bare ``which("npx")`` can miss them depending on PATHEXT. We try
|
||||
the bare name first, then the common suffixes.
|
||||
"""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
if IS_WINDOWS:
|
||||
for ext in (".cmd", ".exe", ".bat"):
|
||||
found = shutil.which(name + ext)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def run_script_argv(script_path) -> List[str]:
|
||||
"""argv to execute a shell *script file*.
|
||||
|
||||
Prefers bash (so existing ``.sh`` wrappers work verbatim, including on
|
||||
Windows via Git Bash). On Windows with no bash available, falls back to
|
||||
``cmd.exe /c`` — simple commands still run, but bash-specific syntax won't.
|
||||
Callers that need guaranteed bash should check :func:`has_bash` first and
|
||||
surface a clear "install Git Bash" message.
|
||||
"""
|
||||
bash = find_bash()
|
||||
if bash:
|
||||
return [bash, str(script_path)]
|
||||
if IS_WINDOWS:
|
||||
comspec = os.environ.get("ComSpec", "cmd.exe")
|
||||
return [comspec, "/c", str(script_path)]
|
||||
return ["sh", str(script_path)]
|
||||
Reference in New Issue
Block a user