feat: implement dynamic profile discovery and improve UI responsiveness for service management
This commit is contained in:
@@ -31,6 +31,16 @@ STATE_DIR = RUNTIME_DIR / "state"
|
||||
DEFAULT_LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
DEFAULT_LOG_BACKUPS = 3
|
||||
DEFAULT_STOP_TIMEOUT_SECONDS = 5.0
|
||||
DEFAULT_SERVICE_PATHS = [
|
||||
"/opt/homebrew/bin",
|
||||
"/opt/homebrew/sbin",
|
||||
"/usr/local/bin",
|
||||
"/usr/local/sbin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -126,14 +136,35 @@ def resolve_workspace_path(raw: str) -> Path:
|
||||
return path if path.is_absolute() else ROOT / path
|
||||
|
||||
|
||||
def command_exists(command: str) -> bool:
|
||||
def service_env(profile: str | None = None) -> dict[str, str]:
|
||||
"""Return a robust environment for services launched from shells or GUI apps.
|
||||
|
||||
macOS login items do not reliably inherit the user's interactive shell PATH.
|
||||
Homebrew-installed tools such as mitmdump commonly live under /opt/homebrew/bin
|
||||
or /usr/local/bin, so make those paths available to both direct service
|
||||
commands and nested scripts.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
existing = [part for part in env.get("PATH", "").split(os.pathsep) if part]
|
||||
merged: list[str] = []
|
||||
for part in [*existing, *DEFAULT_SERVICE_PATHS]:
|
||||
if part not in merged:
|
||||
merged.append(part)
|
||||
env["PATH"] = os.pathsep.join(merged)
|
||||
env.setdefault("AIW_WORKSPACE_ROOT", str(ROOT))
|
||||
if profile:
|
||||
env.setdefault("AIW_PROJECT_PROFILE", profile)
|
||||
return env
|
||||
|
||||
|
||||
def command_exists(command: str, env: dict[str, str] | None = None) -> bool:
|
||||
if not command:
|
||||
return False
|
||||
path = Path(command)
|
||||
if path.is_absolute() or "/" in command:
|
||||
resolved = resolve_workspace_path(command)
|
||||
return resolved.exists() and os.access(resolved, os.X_OK)
|
||||
return shutil_which(command) is not None
|
||||
return shutil_which(command, env=env) is not None
|
||||
|
||||
|
||||
def rotate_log_if_needed(path: Path, max_bytes: int = DEFAULT_LOG_MAX_BYTES, backups: int = DEFAULT_LOG_BACKUPS) -> None:
|
||||
@@ -273,9 +304,10 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
|
||||
|
||||
kind = ref.config.get("kind", "process")
|
||||
command = ref.config.get("command") or []
|
||||
env = service_env(profile)
|
||||
if not command:
|
||||
raise SystemExit(f"{ref.name} has no command")
|
||||
if not command_exists(str(command[0])):
|
||||
if not command_exists(str(command[0]), env=env):
|
||||
raise SystemExit(f"{ref.name} command is not executable or not found: {command[0]}")
|
||||
|
||||
if kind != "app-launcher":
|
||||
@@ -298,11 +330,11 @@ def start_service(profile: str, ref: ServiceRef, manifest: dict[str, Any], start
|
||||
with path.open("ab") as log_file:
|
||||
log_file.write(f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n".encode("utf-8"))
|
||||
if kind == "app-launcher":
|
||||
result = subprocess.run(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, check=False)
|
||||
result = subprocess.run(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, check=False)
|
||||
write_state(profile, ref.name, {"last_launch_exit": result.returncode, "launched_at": time.time()})
|
||||
print(f"{ref.name}: launched (exit {result.returncode})")
|
||||
else:
|
||||
process = subprocess.Popen(command, cwd=ROOT, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
process = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log_file, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
pid_file = pid_path(profile, ref.name)
|
||||
pid_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
pid_file.write_text(str(process.pid) + "\n", encoding="utf-8")
|
||||
@@ -385,6 +417,7 @@ def tail_log(profile: str, service: str, lines: int) -> None:
|
||||
|
||||
def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
errors = validate_manifest(manifest)
|
||||
env = service_env(profile)
|
||||
service_reports = []
|
||||
for ref in service_items(manifest, include_disabled=True):
|
||||
command = ref.config.get("command") or []
|
||||
@@ -392,15 +425,15 @@ def doctor_report(profile: str, manifest: dict[str, Any]) -> dict[str, Any]:
|
||||
doctor = ref.config.get("doctor") or {}
|
||||
checks = []
|
||||
for command_name in doctor.get("required_commands") or []:
|
||||
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name)})
|
||||
checks.append({"type": "required_command", "name": command_name, "ok": command_exists(command_name, env=env)})
|
||||
for command_name in doctor.get("optional_commands") or []:
|
||||
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name)})
|
||||
checks.append({"type": "optional_command", "name": command_name, "ok": command_exists(command_name, env=env)})
|
||||
for raw_path in doctor.get("required_paths") or []:
|
||||
checks.append({"type": "required_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
|
||||
for raw_path in doctor.get("optional_paths") or []:
|
||||
checks.append({"type": "optional_path", "name": str(raw_path), "ok": resolve_workspace_path(str(raw_path)).exists()})
|
||||
status = service_status(profile, ref)
|
||||
status["command_ok"] = command_exists(first) if first else False
|
||||
status["command_ok"] = command_exists(first, env=env) if first else False
|
||||
status["checks"] = checks
|
||||
service_reports.append(status)
|
||||
return {
|
||||
@@ -443,8 +476,8 @@ def run_doctor(profile: str, manifest: dict[str, Any], json_output: bool = False
|
||||
print(f" {label} {check['name']}: {'ok' if check['ok'] else 'missing'}")
|
||||
|
||||
|
||||
def shutil_which(command: str) -> str | None:
|
||||
paths = os.environ.get("PATH", "").split(os.pathsep)
|
||||
def shutil_which(command: str, env: dict[str, str] | None = None) -> str | None:
|
||||
paths = (env or os.environ).get("PATH", "").split(os.pathsep)
|
||||
for directory in paths:
|
||||
candidate = Path(directory) / command
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
|
||||
Reference in New Issue
Block a user