code-aide 1.12.3__tar.gz → 1.14.0__tar.gz
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.
- {code_aide-1.12.3 → code_aide-1.14.0}/PKG-INFO +1 -1
- {code_aide-1.12.3 → code_aide-1.14.0}/script-archive/amp-install.sh +2 -9
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/__init__.py +1 -1
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/commands_tools.py +7 -6
- code_aide-1.14.0/src/code_aide/constants.py +51 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/data/tools.json +4 -1
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/install.py +23 -1
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/operations.py +45 -1
- code_aide-1.14.0/src/code_aide/package_managers.py +188 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/prereqs.py +19 -28
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_commands_tools.py +2 -2
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_install.py +96 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_operations.py +59 -0
- code_aide-1.14.0/tests/test_package_managers.py +277 -0
- code_aide-1.12.3/src/code_aide/constants.py +0 -104
- {code_aide-1.12.3 → code_aide-1.14.0}/.github/workflows/ci.yml +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/.github/workflows/publish.yml +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/.gitignore +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/.gitlab-ci.yml +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/.pre-commit-config.yaml +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/AGENTS.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/CLAUDE.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/LICENSE +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/README.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/TODO.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/pyproject.toml +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/script-archive/README.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/script-archive/claude-install.sh +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/script-archive/cursor-install.sh +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/auto-migrate-deprecated-installs.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/claude-native-installer-migration.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/missing-coding-llm-cli-tools.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/pre-commit-uv-setup.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/remove-bundled-version-baseline.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/__main__.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/commands_actions.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/config.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/console.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/detection.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/entry.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/install_types.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/status.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/src/code_aide/versions.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_commands_actions.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_config.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_console.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_constants.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_detection.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_install_types.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_status.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/tests/test_versions.py +0 -0
- {code_aide-1.12.3 → code_aide-1.14.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-aide
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.14.0
|
|
4
4
|
Summary: Manage AI coding CLI tools (Claude, Copilot, Cursor, Gemini, Amp, Codex)
|
|
5
5
|
Project-URL: Homepage, https://github.com/dajobe/code-aide
|
|
6
6
|
Project-URL: Repository, https://github.com/dajobe/code-aide
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
|
-
# Amp CLI Binary Installation Script (EXPERIMENTAL - NOT DOCUMENTED)
|
|
5
|
-
# This is a secondary install script for testing binary distribution
|
|
6
|
-
# Downloads pre-compiled Amp CLI binary instead of using npm
|
|
7
|
-
|
|
8
4
|
# Configuration
|
|
9
5
|
AMP_HOME="${AMP_HOME:-$HOME/.amp}"
|
|
10
6
|
BIN_DIR="$AMP_HOME/bin"
|
|
@@ -437,11 +433,8 @@ update_shell_profile() {
|
|
|
437
433
|
return
|
|
438
434
|
fi
|
|
439
435
|
else
|
|
440
|
-
# Non-interactive:
|
|
441
|
-
log "
|
|
442
|
-
log "To use amp, add ~/.local/bin to your PATH:"
|
|
443
|
-
echo " $path_export"
|
|
444
|
-
return
|
|
436
|
+
# Non-interactive: add automatically
|
|
437
|
+
log "Adding ~/.local/bin to PATH in $tilde_profile..."
|
|
445
438
|
fi
|
|
446
439
|
|
|
447
440
|
# Create config file if it doesn't exist
|
|
@@ -8,7 +8,7 @@ import shutil
|
|
|
8
8
|
from typing import List
|
|
9
9
|
|
|
10
10
|
from code_aide.config import ensure_versions_cache
|
|
11
|
-
from code_aide.constants import Colors,
|
|
11
|
+
from code_aide.constants import Colors, TOOLS
|
|
12
12
|
from code_aide.detection import (
|
|
13
13
|
format_install_method,
|
|
14
14
|
format_migration_warning,
|
|
@@ -20,7 +20,10 @@ from code_aide.install_types import (
|
|
|
20
20
|
parse_install_method,
|
|
21
21
|
parse_install_type,
|
|
22
22
|
)
|
|
23
|
-
from code_aide.
|
|
23
|
+
from code_aide.package_managers import (
|
|
24
|
+
detect_package_manager as _detect_package_manager,
|
|
25
|
+
)
|
|
26
|
+
from code_aide.prereqs import is_tool_installed
|
|
24
27
|
from code_aide.detection import is_freebsd
|
|
25
28
|
from code_aide.status import (
|
|
26
29
|
print_brew_version_status,
|
|
@@ -107,11 +110,9 @@ def cmd_list(args: argparse.Namespace) -> None:
|
|
|
107
110
|
except Exception:
|
|
108
111
|
pass
|
|
109
112
|
|
|
110
|
-
pkg_mgr =
|
|
113
|
+
pkg_mgr = _detect_package_manager()
|
|
111
114
|
if pkg_mgr:
|
|
112
|
-
print(
|
|
113
|
-
f" Package manager: {PACKAGE_MANAGERS[pkg_mgr]['description']} ({pkg_mgr})"
|
|
114
|
-
)
|
|
115
|
+
print(f" Package manager: {pkg_mgr.description} ({pkg_mgr.detect_command})")
|
|
115
116
|
|
|
116
117
|
|
|
117
118
|
def _short_install_method(method: str | None) -> str:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared constants and mutable tool configuration for CLI modules."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
|
|
7
|
+
from code_aide.config import load_tools_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _use_color() -> bool:
|
|
11
|
+
"""Determine whether to emit ANSI color codes.
|
|
12
|
+
|
|
13
|
+
Checks (in priority order):
|
|
14
|
+
NO_COLOR — if set (any value), no color (https://no-color.org/)
|
|
15
|
+
FORCE_COLOR — if set (any value), force color
|
|
16
|
+
CLICOLOR_FORCE — if non-empty, force color
|
|
17
|
+
TERM=dumb — no color
|
|
18
|
+
CLICOLOR=0 — no color
|
|
19
|
+
stdout is not a TTY — no color
|
|
20
|
+
"""
|
|
21
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
22
|
+
return False
|
|
23
|
+
if os.environ.get("FORCE_COLOR") is not None:
|
|
24
|
+
return True
|
|
25
|
+
if os.environ.get("CLICOLOR_FORCE", ""):
|
|
26
|
+
return True
|
|
27
|
+
if os.environ.get("TERM") == "dumb":
|
|
28
|
+
return False
|
|
29
|
+
if os.environ.get("CLICOLOR") == "0":
|
|
30
|
+
return False
|
|
31
|
+
return sys.stdout.isatty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Colors:
|
|
35
|
+
if _use_color():
|
|
36
|
+
RED = "\033[0;31m"
|
|
37
|
+
GREEN = "\033[0;32m"
|
|
38
|
+
YELLOW = "\033[1;33m"
|
|
39
|
+
BLUE = "\033[0;34m"
|
|
40
|
+
BOLD = "\033[1m"
|
|
41
|
+
NC = "\033[0m"
|
|
42
|
+
else:
|
|
43
|
+
RED = ""
|
|
44
|
+
GREEN = ""
|
|
45
|
+
YELLOW = ""
|
|
46
|
+
BLUE = ""
|
|
47
|
+
BOLD = ""
|
|
48
|
+
NC = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
TOOLS: Dict[str, Dict[str, Any]] = load_tools_config()
|
|
@@ -64,7 +64,10 @@
|
|
|
64
64
|
"command": "amp",
|
|
65
65
|
"install_type": "script",
|
|
66
66
|
"install_url": "https://ampcode.com/install.sh",
|
|
67
|
-
"install_sha256": "
|
|
67
|
+
"install_sha256": "2442f20759a2be3beaef6691c7e5bf349f469eb0ff7ea2885746500243446e24",
|
|
68
|
+
"install_script_path_prepend": [
|
|
69
|
+
"~/.local/bin"
|
|
70
|
+
],
|
|
68
71
|
"version_url": "https://storage.googleapis.com/amp-public-assets-prod-0/cli/cli-version.txt",
|
|
69
72
|
"prerequisites": [],
|
|
70
73
|
"min_node_version": null,
|
|
@@ -43,6 +43,7 @@ def run_install_script(
|
|
|
43
43
|
tool_name: str,
|
|
44
44
|
expected_sha256: Optional[str] = None,
|
|
45
45
|
dryrun: bool = False,
|
|
46
|
+
env: Optional[Dict[str, str]] = None,
|
|
46
47
|
) -> bool:
|
|
47
48
|
"""Download and run an installation script with SHA256 verification."""
|
|
48
49
|
try:
|
|
@@ -82,6 +83,7 @@ def run_install_script(
|
|
|
82
83
|
["bash"],
|
|
83
84
|
stdin=subprocess.PIPE,
|
|
84
85
|
stderr=subprocess.PIPE,
|
|
86
|
+
env=env,
|
|
85
87
|
)
|
|
86
88
|
_, stderr = bash_process.communicate(input=script_content)
|
|
87
89
|
|
|
@@ -99,6 +101,22 @@ def run_install_script(
|
|
|
99
101
|
return False
|
|
100
102
|
|
|
101
103
|
|
|
104
|
+
def get_install_script_env(tool_config: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
|
105
|
+
"""Return environment overrides for running a tool's install script."""
|
|
106
|
+
path_prepend = tool_config.get("install_script_path_prepend", [])
|
|
107
|
+
if not path_prepend:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
env = os.environ.copy()
|
|
111
|
+
expanded_paths = [os.path.expanduser(path) for path in path_prepend]
|
|
112
|
+
current_path = env.get("PATH", "")
|
|
113
|
+
path_parts = [*expanded_paths]
|
|
114
|
+
if current_path:
|
|
115
|
+
path_parts.append(current_path)
|
|
116
|
+
env["PATH"] = os.pathsep.join(path_parts)
|
|
117
|
+
return env
|
|
118
|
+
|
|
119
|
+
|
|
102
120
|
ARCH_MAP = {
|
|
103
121
|
"x86_64": "x64",
|
|
104
122
|
"amd64": "x64",
|
|
@@ -327,7 +345,11 @@ def install_tool(tool_name: str, dryrun: bool = False, force: bool = False) -> b
|
|
|
327
345
|
install_url = tool_config["install_url"]
|
|
328
346
|
expected_sha256 = tool_config.get("install_sha256")
|
|
329
347
|
if run_install_script(
|
|
330
|
-
install_url,
|
|
348
|
+
install_url,
|
|
349
|
+
tool_config["name"],
|
|
350
|
+
expected_sha256,
|
|
351
|
+
dryrun,
|
|
352
|
+
env=get_install_script_env(tool_config),
|
|
331
353
|
):
|
|
332
354
|
if dryrun:
|
|
333
355
|
success(f"{tool_config['name']} verification passed")
|
|
@@ -14,7 +14,9 @@ from code_aide.detection import (
|
|
|
14
14
|
format_install_method,
|
|
15
15
|
is_deprecated_install,
|
|
16
16
|
)
|
|
17
|
+
from code_aide.package_managers import query_package_owner
|
|
17
18
|
from code_aide.install import (
|
|
19
|
+
get_install_script_env,
|
|
18
20
|
install_direct_download,
|
|
19
21
|
install_tool,
|
|
20
22
|
run_install_script,
|
|
@@ -83,6 +85,44 @@ def _upgrade_result_from_snapshots(
|
|
|
83
85
|
return UpgradeResult.CHANGED
|
|
84
86
|
|
|
85
87
|
|
|
88
|
+
def _warn_duplicate_system_install(tool_name: str) -> None:
|
|
89
|
+
"""Warn if a duplicate system-packaged binary shadows or coexists."""
|
|
90
|
+
tool_config = TOOLS[tool_name]
|
|
91
|
+
command = tool_config["command"]
|
|
92
|
+
|
|
93
|
+
# Find all instances of the command in PATH
|
|
94
|
+
seen = set()
|
|
95
|
+
paths = []
|
|
96
|
+
for directory in os.environ.get("PATH", "").split(os.pathsep):
|
|
97
|
+
candidate = os.path.join(directory, command)
|
|
98
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
99
|
+
real = os.path.realpath(candidate)
|
|
100
|
+
if real not in seen:
|
|
101
|
+
seen.add(real)
|
|
102
|
+
paths.append(real)
|
|
103
|
+
|
|
104
|
+
if len(paths) < 2:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
system_prefixes = ("/opt/", "/usr/bin/", "/usr/sbin/", "/usr/local/bin/")
|
|
108
|
+
for path in paths:
|
|
109
|
+
if not any(path.startswith(p) for p in system_prefixes):
|
|
110
|
+
continue
|
|
111
|
+
package, remove_cmd = query_package_owner(path)
|
|
112
|
+
if package and remove_cmd:
|
|
113
|
+
warning(
|
|
114
|
+
f"A system-packaged {command} is also installed at {path} "
|
|
115
|
+
f"(package: {package})."
|
|
116
|
+
)
|
|
117
|
+
info(f"To remove it, run: {remove_cmd}")
|
|
118
|
+
else:
|
|
119
|
+
warning(
|
|
120
|
+
f"A system-packaged {command} is also installed at {path}. "
|
|
121
|
+
"You may want to remove it with your package manager."
|
|
122
|
+
)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
|
|
86
126
|
def _migrate_install_method(tool_name: str) -> UpgradeResult:
|
|
87
127
|
"""Migrate a tool from a deprecated install method to the configured one.
|
|
88
128
|
|
|
@@ -129,6 +169,7 @@ def _migrate_install_method(tool_name: str) -> UpgradeResult:
|
|
|
129
169
|
return UpgradeResult.FAILED
|
|
130
170
|
|
|
131
171
|
success(f"{tool_config['name']} migrated from {old_label} to {new_label}")
|
|
172
|
+
_warn_duplicate_system_install(tool_name)
|
|
132
173
|
return UpgradeResult.CHANGED
|
|
133
174
|
|
|
134
175
|
|
|
@@ -185,7 +226,10 @@ def upgrade_tool(tool_name: str) -> UpgradeResult:
|
|
|
185
226
|
install_url = tool_config["install_url"]
|
|
186
227
|
expected_sha256 = tool_config.get("install_sha256")
|
|
187
228
|
if run_install_script(
|
|
188
|
-
install_url,
|
|
229
|
+
install_url,
|
|
230
|
+
tool_config["name"],
|
|
231
|
+
expected_sha256,
|
|
232
|
+
env=get_install_script_env(tool_config),
|
|
189
233
|
):
|
|
190
234
|
pass
|
|
191
235
|
else:
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""System package manager definitions and helpers."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PackageManager(Enum):
|
|
13
|
+
"""Known system package managers."""
|
|
14
|
+
|
|
15
|
+
APT = "apt-get"
|
|
16
|
+
DNF = "dnf"
|
|
17
|
+
YUM = "yum"
|
|
18
|
+
PACMAN = "pacman"
|
|
19
|
+
ZYPPER = "zypper"
|
|
20
|
+
EMERGE = "emerge"
|
|
21
|
+
PKG = "pkg"
|
|
22
|
+
HOMEBREW = "brew"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class PackageManagerInfo:
|
|
27
|
+
"""Configuration for a system package manager."""
|
|
28
|
+
|
|
29
|
+
manager: PackageManager
|
|
30
|
+
description: str
|
|
31
|
+
detect_command: str
|
|
32
|
+
packages: List[str]
|
|
33
|
+
install_command: List[str]
|
|
34
|
+
pre_install: List[List[str]] = field(default_factory=list)
|
|
35
|
+
query_owner_command: List[str] = field(default_factory=list)
|
|
36
|
+
remove_command: List[str] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_MANAGERS: List[PackageManagerInfo] = [
|
|
40
|
+
PackageManagerInfo(
|
|
41
|
+
manager=PackageManager.APT,
|
|
42
|
+
description="Debian/Ubuntu",
|
|
43
|
+
detect_command="apt-get",
|
|
44
|
+
packages=["nodejs", "npm"],
|
|
45
|
+
pre_install=[["sudo", "apt-get", "update"]],
|
|
46
|
+
install_command=["sudo", "apt-get", "install", "-y"],
|
|
47
|
+
query_owner_command=["dpkg", "-S"],
|
|
48
|
+
remove_command=["sudo", "apt-get", "remove"],
|
|
49
|
+
),
|
|
50
|
+
PackageManagerInfo(
|
|
51
|
+
manager=PackageManager.DNF,
|
|
52
|
+
description="Fedora/RHEL 8+",
|
|
53
|
+
detect_command="dnf",
|
|
54
|
+
packages=["nodejs", "npm"],
|
|
55
|
+
install_command=["sudo", "dnf", "install", "-y"],
|
|
56
|
+
query_owner_command=["rpm", "-qf"],
|
|
57
|
+
remove_command=["sudo", "dnf", "remove"],
|
|
58
|
+
),
|
|
59
|
+
PackageManagerInfo(
|
|
60
|
+
manager=PackageManager.YUM,
|
|
61
|
+
description="RHEL/CentOS 7",
|
|
62
|
+
detect_command="yum",
|
|
63
|
+
packages=["nodejs", "npm"],
|
|
64
|
+
install_command=["sudo", "yum", "install", "-y"],
|
|
65
|
+
query_owner_command=["rpm", "-qf"],
|
|
66
|
+
remove_command=["sudo", "yum", "remove"],
|
|
67
|
+
),
|
|
68
|
+
PackageManagerInfo(
|
|
69
|
+
manager=PackageManager.PACMAN,
|
|
70
|
+
description="Arch Linux",
|
|
71
|
+
detect_command="pacman",
|
|
72
|
+
packages=["nodejs", "npm"],
|
|
73
|
+
install_command=["sudo", "pacman", "-S", "--noconfirm"],
|
|
74
|
+
query_owner_command=["pacman", "-Qo"],
|
|
75
|
+
remove_command=["sudo", "pacman", "-R"],
|
|
76
|
+
),
|
|
77
|
+
PackageManagerInfo(
|
|
78
|
+
manager=PackageManager.ZYPPER,
|
|
79
|
+
description="openSUSE",
|
|
80
|
+
detect_command="zypper",
|
|
81
|
+
packages=["nodejs", "npm"],
|
|
82
|
+
install_command=["sudo", "zypper", "install", "-y"],
|
|
83
|
+
query_owner_command=["rpm", "-qf"],
|
|
84
|
+
remove_command=["sudo", "zypper", "remove"],
|
|
85
|
+
),
|
|
86
|
+
PackageManagerInfo(
|
|
87
|
+
manager=PackageManager.EMERGE,
|
|
88
|
+
description="Gentoo",
|
|
89
|
+
detect_command="emerge",
|
|
90
|
+
packages=["net-libs/nodejs"],
|
|
91
|
+
install_command=["sudo", "emerge", "--quiet-build"],
|
|
92
|
+
query_owner_command=["qfile", "-qC"],
|
|
93
|
+
remove_command=["sudo", "emerge", "--unmerge"],
|
|
94
|
+
),
|
|
95
|
+
PackageManagerInfo(
|
|
96
|
+
manager=PackageManager.PKG,
|
|
97
|
+
description="FreeBSD",
|
|
98
|
+
detect_command="pkg",
|
|
99
|
+
packages=["node22", "npm-node22"],
|
|
100
|
+
install_command=["sudo", "pkg", "install", "-y"],
|
|
101
|
+
query_owner_command=["pkg", "which", "-q"],
|
|
102
|
+
remove_command=["sudo", "pkg", "delete"],
|
|
103
|
+
),
|
|
104
|
+
PackageManagerInfo(
|
|
105
|
+
manager=PackageManager.HOMEBREW,
|
|
106
|
+
description="macOS/Linux (Homebrew)",
|
|
107
|
+
detect_command="brew",
|
|
108
|
+
packages=["node"],
|
|
109
|
+
install_command=["brew", "install"],
|
|
110
|
+
query_owner_command=["brew", "which-formula"],
|
|
111
|
+
remove_command=["brew", "uninstall"],
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
PACKAGE_MANAGER_BY_ENUM = {info.manager: info for info in _MANAGERS}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def detect_package_manager() -> Optional[PackageManagerInfo]:
|
|
119
|
+
"""Detect the system package manager, or None on unsupported platforms."""
|
|
120
|
+
if platform.system() not in ("Linux", "FreeBSD", "Darwin"):
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
for info in _MANAGERS:
|
|
124
|
+
if shutil.which(info.detect_command):
|
|
125
|
+
return info
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_package_name(manager: PackageManager, query_output: str) -> Optional[str]:
|
|
131
|
+
"""Extract a package name from query-owner output."""
|
|
132
|
+
line = query_output.strip().split("\n")[0]
|
|
133
|
+
if not line:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
if manager == PackageManager.APT:
|
|
137
|
+
# dpkg -S output: "package: /path/to/file"
|
|
138
|
+
return line.split(":")[0].strip() or None
|
|
139
|
+
if manager == PackageManager.PACMAN:
|
|
140
|
+
# pacman -Qo output: "/path is owned by package version"
|
|
141
|
+
parts = line.split("is owned by")
|
|
142
|
+
if len(parts) > 1:
|
|
143
|
+
return parts[1].strip().rsplit(" ", 1)[0] or None
|
|
144
|
+
return None
|
|
145
|
+
# rpm -qf (dnf/yum/zypper): "package-version-release.arch"
|
|
146
|
+
# qfile -qC (emerge): "category/package"
|
|
147
|
+
# pkg which -q (FreeBSD): "package-name"
|
|
148
|
+
return line.strip() or None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def query_package_owner(
|
|
152
|
+
binary_path: str,
|
|
153
|
+
) -> tuple:
|
|
154
|
+
"""Identify the system package that owns a binary path.
|
|
155
|
+
|
|
156
|
+
Returns (package_name, remove_command_str) or (None, None).
|
|
157
|
+
"""
|
|
158
|
+
mgr = detect_package_manager()
|
|
159
|
+
if not mgr or not mgr.query_owner_command or not mgr.remove_command:
|
|
160
|
+
return None, None
|
|
161
|
+
|
|
162
|
+
# brew which-formula takes the command name, not the full path.
|
|
163
|
+
query_arg = (
|
|
164
|
+
os.path.basename(binary_path)
|
|
165
|
+
if mgr.manager == PackageManager.HOMEBREW
|
|
166
|
+
else binary_path
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
proc = subprocess.run(
|
|
171
|
+
[*mgr.query_owner_command, query_arg],
|
|
172
|
+
capture_output=True,
|
|
173
|
+
text=True,
|
|
174
|
+
timeout=10,
|
|
175
|
+
check=False,
|
|
176
|
+
stdin=subprocess.DEVNULL,
|
|
177
|
+
)
|
|
178
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
179
|
+
return None, None
|
|
180
|
+
except Exception:
|
|
181
|
+
return None, None
|
|
182
|
+
|
|
183
|
+
package = _parse_package_name(mgr.manager, proc.stdout)
|
|
184
|
+
if not package:
|
|
185
|
+
return None, None
|
|
186
|
+
|
|
187
|
+
remove_cmd = " ".join([*mgr.remove_command, package])
|
|
188
|
+
return package, remove_cmd
|
|
@@ -6,7 +6,7 @@ import subprocess
|
|
|
6
6
|
import sys
|
|
7
7
|
from typing import List, Optional
|
|
8
8
|
|
|
9
|
-
from code_aide.constants import
|
|
9
|
+
from code_aide.constants import TOOLS
|
|
10
10
|
from code_aide.console import (
|
|
11
11
|
command_exists,
|
|
12
12
|
error,
|
|
@@ -15,42 +15,38 @@ from code_aide.console import (
|
|
|
15
15
|
success,
|
|
16
16
|
warning,
|
|
17
17
|
)
|
|
18
|
+
from code_aide.package_managers import (
|
|
19
|
+
_MANAGERS,
|
|
20
|
+
detect_package_manager,
|
|
21
|
+
)
|
|
18
22
|
|
|
19
23
|
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if command_exists(config["detect_command"]):
|
|
27
|
-
return pkg_mgr_name
|
|
28
|
-
|
|
29
|
-
return None
|
|
24
|
+
def _print_all_install_hints() -> None:
|
|
25
|
+
"""Print install commands for all known package managers."""
|
|
26
|
+
for mgr in _MANAGERS:
|
|
27
|
+
install_cmd = " ".join(mgr.install_command + mgr.packages)
|
|
28
|
+
print(f" {mgr.description}: {install_cmd}")
|
|
29
|
+
print(" Or visit: https://nodejs.org/")
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def install_nodejs_npm() -> bool:
|
|
33
33
|
"""Install Node.js and npm using the system package manager."""
|
|
34
|
-
|
|
34
|
+
mgr = detect_package_manager()
|
|
35
35
|
|
|
36
|
-
if not
|
|
36
|
+
if not mgr:
|
|
37
37
|
error("Could not detect package manager. Please install Node.js manually:")
|
|
38
|
-
|
|
39
|
-
install_cmd = " ".join(config["install_command"] + config["packages"])
|
|
40
|
-
print(f" {config['description']}: {install_cmd}")
|
|
41
|
-
print(" Or visit: https://nodejs.org/")
|
|
38
|
+
_print_all_install_hints()
|
|
42
39
|
return False
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
info(f"Detected package manager: {pkg_mgr_name} ({config['description']})")
|
|
41
|
+
info(f"Detected package manager: {mgr.detect_command} ({mgr.description})")
|
|
46
42
|
info("Installing Node.js and npm...")
|
|
47
43
|
|
|
48
44
|
try:
|
|
49
|
-
for pre_cmd in
|
|
45
|
+
for pre_cmd in mgr.pre_install:
|
|
50
46
|
info(f"Running: {' '.join(pre_cmd)}")
|
|
51
|
-
run_command(pre_cmd, check=True, capture=False)
|
|
47
|
+
run_command(list(pre_cmd), check=True, capture=False)
|
|
52
48
|
|
|
53
|
-
install_cmd =
|
|
49
|
+
install_cmd = list(mgr.install_command) + list(mgr.packages)
|
|
54
50
|
run_command(install_cmd, check=True, capture=False)
|
|
55
51
|
|
|
56
52
|
if not command_exists("npm"):
|
|
@@ -102,12 +98,7 @@ def check_prerequisites(
|
|
|
102
98
|
else:
|
|
103
99
|
error("npm is required but not installed.")
|
|
104
100
|
error("Please install Node.js and npm first:")
|
|
105
|
-
|
|
106
|
-
install_cmd = " ".join(
|
|
107
|
-
config["install_command"] + config["packages"]
|
|
108
|
-
)
|
|
109
|
-
print(f" {config['description']}: {install_cmd}")
|
|
110
|
-
print(" Or visit: https://nodejs.org/")
|
|
101
|
+
_print_all_install_hints()
|
|
111
102
|
print("\nOr use -p/--install-prerequisites to install automatically")
|
|
112
103
|
sys.exit(1)
|
|
113
104
|
|
|
@@ -26,7 +26,7 @@ class TestCmdList(unittest.TestCase):
|
|
|
26
26
|
mock.patch.object(commands_tools, "is_tool_installed", return_value=False),
|
|
27
27
|
mock.patch.object(commands_tools, "command_exists", return_value=False),
|
|
28
28
|
mock.patch.object(
|
|
29
|
-
commands_tools, "
|
|
29
|
+
commands_tools, "_detect_package_manager", return_value=None
|
|
30
30
|
),
|
|
31
31
|
):
|
|
32
32
|
buf = io.StringIO()
|
|
@@ -314,7 +314,7 @@ class TestCmdStatus(unittest.TestCase):
|
|
|
314
314
|
),
|
|
315
315
|
mock.patch.object(commands_tools, "command_exists", return_value=False),
|
|
316
316
|
mock.patch.object(
|
|
317
|
-
commands_tools, "
|
|
317
|
+
commands_tools, "_detect_package_manager", return_value=None
|
|
318
318
|
),
|
|
319
319
|
):
|
|
320
320
|
buf = io.StringIO()
|
|
@@ -148,6 +148,67 @@ class TestExtractTarMember(unittest.TestCase):
|
|
|
148
148
|
self.assertTrue(os.path.exists(extracted))
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
class TestInstallScriptEnv(unittest.TestCase):
|
|
152
|
+
"""Tests for install script environment handling."""
|
|
153
|
+
|
|
154
|
+
def test_no_env_without_path_prepend(self):
|
|
155
|
+
self.assertIsNone(cli_install.get_install_script_env({}))
|
|
156
|
+
|
|
157
|
+
def test_prepends_expanded_paths(self):
|
|
158
|
+
with tempfile.TemporaryDirectory() as td:
|
|
159
|
+
with mock.patch.dict(
|
|
160
|
+
os.environ,
|
|
161
|
+
{"HOME": td, "PATH": "/usr/bin"},
|
|
162
|
+
clear=True,
|
|
163
|
+
):
|
|
164
|
+
env = cli_install.get_install_script_env(
|
|
165
|
+
{"install_script_path_prepend": ["~/.local/bin"]}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self.assertIsNotNone(env)
|
|
169
|
+
self.assertEqual(
|
|
170
|
+
env["PATH"],
|
|
171
|
+
os.path.join(td, ".local", "bin") + os.pathsep + "/usr/bin",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def test_path_prepend_without_existing_path(self):
|
|
175
|
+
with tempfile.TemporaryDirectory() as td:
|
|
176
|
+
with mock.patch.dict(os.environ, {"HOME": td}, clear=True):
|
|
177
|
+
env = cli_install.get_install_script_env(
|
|
178
|
+
{"install_script_path_prepend": ["~/.local/bin"]}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
self.assertIsNotNone(env)
|
|
182
|
+
self.assertEqual(env["PATH"], os.path.join(td, ".local", "bin"))
|
|
183
|
+
|
|
184
|
+
@mock.patch.object(cli_install, "fetch_url")
|
|
185
|
+
@mock.patch.object(cli_install.subprocess, "Popen")
|
|
186
|
+
def test_run_install_script_passes_env_to_bash(self, mock_popen, mock_fetch):
|
|
187
|
+
script_content = b"#!/usr/bin/env bash\n"
|
|
188
|
+
expected_sha256 = cli_install.hashlib.sha256(script_content).hexdigest()
|
|
189
|
+
process = mock.Mock()
|
|
190
|
+
process.communicate.return_value = (b"", b"")
|
|
191
|
+
process.returncode = 0
|
|
192
|
+
mock_popen.return_value = process
|
|
193
|
+
mock_fetch.return_value = (script_content, None)
|
|
194
|
+
env = {"PATH": "/tmp/bin:/usr/bin"}
|
|
195
|
+
|
|
196
|
+
result = cli_install.run_install_script(
|
|
197
|
+
"https://example.com/install.sh",
|
|
198
|
+
"Example",
|
|
199
|
+
expected_sha256,
|
|
200
|
+
env=env,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
self.assertTrue(result)
|
|
204
|
+
mock_popen.assert_called_once_with(
|
|
205
|
+
["bash"],
|
|
206
|
+
stdin=cli_install.subprocess.PIPE,
|
|
207
|
+
stderr=cli_install.subprocess.PIPE,
|
|
208
|
+
env=env,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
151
212
|
class TestInstallDirectDownloadDryrun(unittest.TestCase):
|
|
152
213
|
"""Tests for install_direct_download in dryrun mode."""
|
|
153
214
|
|
|
@@ -270,3 +331,38 @@ class TestInstallTool(unittest.TestCase):
|
|
|
270
331
|
"/usr/local/bin/test-tool"
|
|
271
332
|
)
|
|
272
333
|
mock_info.assert_any_call("[DRYRUN] Would install npm package: test-tool")
|
|
334
|
+
|
|
335
|
+
def test_script_install_passes_configured_env(self):
|
|
336
|
+
tool_config = {
|
|
337
|
+
"name": "Test Tool",
|
|
338
|
+
"command": "test-tool",
|
|
339
|
+
"install_type": "script",
|
|
340
|
+
"install_url": "https://example.com/install.sh",
|
|
341
|
+
"install_sha256": "abc123",
|
|
342
|
+
"install_script_path_prepend": ["~/.local/bin"],
|
|
343
|
+
"next_steps": "Run test-tool",
|
|
344
|
+
}
|
|
345
|
+
env = {"PATH": "/tmp/bin:/usr/bin"}
|
|
346
|
+
|
|
347
|
+
with (
|
|
348
|
+
mock.patch.dict(cli_install.TOOLS, {"test": tool_config}, clear=True),
|
|
349
|
+
mock.patch.object(cli_install, "command_exists", return_value=False),
|
|
350
|
+
mock.patch.object(cli_install.platform, "system", return_value="Darwin"),
|
|
351
|
+
mock.patch.object(
|
|
352
|
+
cli_install, "get_install_script_env", return_value=env
|
|
353
|
+
) as mock_env,
|
|
354
|
+
mock.patch.object(
|
|
355
|
+
cli_install, "run_install_script", return_value=True
|
|
356
|
+
) as mock_script,
|
|
357
|
+
):
|
|
358
|
+
result = cli_install.install_tool("test")
|
|
359
|
+
|
|
360
|
+
self.assertTrue(result)
|
|
361
|
+
mock_env.assert_called_once_with(tool_config)
|
|
362
|
+
mock_script.assert_called_once_with(
|
|
363
|
+
"https://example.com/install.sh",
|
|
364
|
+
"Test Tool",
|
|
365
|
+
"abc123",
|
|
366
|
+
False,
|
|
367
|
+
env=env,
|
|
368
|
+
)
|
|
@@ -244,6 +244,65 @@ class TestMigrateInstallMethod(unittest.TestCase):
|
|
|
244
244
|
mock_dd.assert_called_once_with("cursor", tool_config)
|
|
245
245
|
mock_script.assert_not_called()
|
|
246
246
|
|
|
247
|
+
def test_upgrade_script_passes_configured_env(self):
|
|
248
|
+
"""Script upgrades pass per-tool install environment overrides."""
|
|
249
|
+
tool_config = {
|
|
250
|
+
"name": "Amp (Sourcegraph)",
|
|
251
|
+
"command": "amp",
|
|
252
|
+
"install_type": "script",
|
|
253
|
+
"install_url": "https://ampcode.com/install.sh",
|
|
254
|
+
"install_sha256": "abc123",
|
|
255
|
+
"install_script_path_prepend": ["~/.local/bin"],
|
|
256
|
+
}
|
|
257
|
+
env = {"PATH": "/tmp/bin:/usr/bin"}
|
|
258
|
+
with (
|
|
259
|
+
mock.patch.dict(cli_operations.TOOLS, {"amp": tool_config}),
|
|
260
|
+
mock.patch.object(cli_operations, "is_tool_installed", return_value=True),
|
|
261
|
+
mock.patch.object(
|
|
262
|
+
cli_operations, "is_deprecated_install", return_value=False
|
|
263
|
+
),
|
|
264
|
+
mock.patch.object(
|
|
265
|
+
cli_operations,
|
|
266
|
+
"detect_install_method",
|
|
267
|
+
side_effect=[
|
|
268
|
+
{"method": "script", "detail": "native installer"},
|
|
269
|
+
{"method": "script", "detail": "native installer"},
|
|
270
|
+
],
|
|
271
|
+
),
|
|
272
|
+
mock.patch.object(
|
|
273
|
+
cli_operations, "get_install_script_env", return_value=env
|
|
274
|
+
) as mock_env,
|
|
275
|
+
mock.patch.object(
|
|
276
|
+
cli_operations, "run_install_script", return_value=True
|
|
277
|
+
) as mock_script,
|
|
278
|
+
mock.patch.object(
|
|
279
|
+
cli_operations,
|
|
280
|
+
"_get_upgrade_snapshot",
|
|
281
|
+
side_effect=[
|
|
282
|
+
{
|
|
283
|
+
"method": "script",
|
|
284
|
+
"detail": "native installer",
|
|
285
|
+
"version": "1.0.0",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"method": "script",
|
|
289
|
+
"detail": "native installer",
|
|
290
|
+
"version": "2.0.0",
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
),
|
|
294
|
+
):
|
|
295
|
+
result = cli_operations.upgrade_tool("amp")
|
|
296
|
+
|
|
297
|
+
self.assertEqual(result, UpgradeResult.CHANGED)
|
|
298
|
+
mock_env.assert_called_once_with(tool_config)
|
|
299
|
+
mock_script.assert_called_once_with(
|
|
300
|
+
"https://ampcode.com/install.sh",
|
|
301
|
+
"Amp (Sourcegraph)",
|
|
302
|
+
"abc123",
|
|
303
|
+
env=env,
|
|
304
|
+
)
|
|
305
|
+
|
|
247
306
|
def test_migration_fails_on_remove(self):
|
|
248
307
|
"""Migration fails if remove_tool returns False."""
|
|
249
308
|
tool_config = {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Unit tests for the package_managers module."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import unittest
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from code_aide.package_managers import (
|
|
8
|
+
PACKAGE_MANAGER_BY_ENUM,
|
|
9
|
+
PackageManager,
|
|
10
|
+
_MANAGERS,
|
|
11
|
+
_parse_package_name,
|
|
12
|
+
detect_package_manager,
|
|
13
|
+
query_package_owner,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestPackageManagerEnum(unittest.TestCase):
|
|
18
|
+
"""Tests for the PackageManager enum."""
|
|
19
|
+
|
|
20
|
+
def test_all_managers_have_entries(self):
|
|
21
|
+
for mgr in PackageManager:
|
|
22
|
+
self.assertIn(mgr, PACKAGE_MANAGER_BY_ENUM)
|
|
23
|
+
|
|
24
|
+
def test_manager_list_matches_enum(self):
|
|
25
|
+
managers_in_list = {info.manager for info in _MANAGERS}
|
|
26
|
+
self.assertEqual(managers_in_list, set(PackageManager))
|
|
27
|
+
|
|
28
|
+
def test_no_duplicate_detect_commands(self):
|
|
29
|
+
commands = [info.detect_command for info in _MANAGERS]
|
|
30
|
+
self.assertEqual(len(commands), len(set(commands)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestPackageManagerInfo(unittest.TestCase):
|
|
34
|
+
"""Tests for PackageManagerInfo completeness."""
|
|
35
|
+
|
|
36
|
+
def test_all_managers_have_install_command(self):
|
|
37
|
+
for info in _MANAGERS:
|
|
38
|
+
self.assertTrue(
|
|
39
|
+
len(info.install_command) > 0,
|
|
40
|
+
f"{info.manager.name} missing install_command",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def test_all_managers_have_query_owner_command(self):
|
|
44
|
+
for info in _MANAGERS:
|
|
45
|
+
self.assertTrue(
|
|
46
|
+
len(info.query_owner_command) > 0,
|
|
47
|
+
f"{info.manager.name} missing query_owner_command",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def test_all_managers_have_remove_command(self):
|
|
51
|
+
for info in _MANAGERS:
|
|
52
|
+
self.assertTrue(
|
|
53
|
+
len(info.remove_command) > 0,
|
|
54
|
+
f"{info.manager.name} missing remove_command",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def test_all_managers_have_description(self):
|
|
58
|
+
for info in _MANAGERS:
|
|
59
|
+
self.assertTrue(
|
|
60
|
+
len(info.description) > 0,
|
|
61
|
+
f"{info.manager.name} missing description",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def test_all_managers_have_packages(self):
|
|
65
|
+
for info in _MANAGERS:
|
|
66
|
+
self.assertTrue(
|
|
67
|
+
len(info.packages) > 0,
|
|
68
|
+
f"{info.manager.name} missing packages",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def test_info_is_frozen(self):
|
|
72
|
+
info = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
|
|
73
|
+
with self.assertRaises(AttributeError):
|
|
74
|
+
info.description = "changed"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestDetectPackageManager(unittest.TestCase):
|
|
78
|
+
"""Tests for detect_package_manager."""
|
|
79
|
+
|
|
80
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Windows")
|
|
81
|
+
def test_returns_none_on_windows(self, _mock_sys):
|
|
82
|
+
self.assertIsNone(detect_package_manager())
|
|
83
|
+
|
|
84
|
+
@mock.patch("code_aide.package_managers.shutil.which", return_value=None)
|
|
85
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
|
|
86
|
+
def test_returns_none_when_no_manager_found(self, _mock_sys, _mock_which):
|
|
87
|
+
self.assertIsNone(detect_package_manager())
|
|
88
|
+
|
|
89
|
+
@mock.patch("code_aide.package_managers.shutil.which")
|
|
90
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
|
|
91
|
+
def test_detects_apt(self, _mock_sys, mock_which):
|
|
92
|
+
mock_which.side_effect = lambda cmd: (
|
|
93
|
+
"/usr/bin/apt-get" if cmd == "apt-get" else None
|
|
94
|
+
)
|
|
95
|
+
result = detect_package_manager()
|
|
96
|
+
self.assertIsNotNone(result)
|
|
97
|
+
self.assertEqual(result.manager, PackageManager.APT)
|
|
98
|
+
|
|
99
|
+
@mock.patch("code_aide.package_managers.shutil.which")
|
|
100
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
|
|
101
|
+
def test_detects_emerge(self, _mock_sys, mock_which):
|
|
102
|
+
mock_which.side_effect = lambda cmd: (
|
|
103
|
+
"/usr/bin/emerge" if cmd == "emerge" else None
|
|
104
|
+
)
|
|
105
|
+
result = detect_package_manager()
|
|
106
|
+
self.assertIsNotNone(result)
|
|
107
|
+
self.assertEqual(result.manager, PackageManager.EMERGE)
|
|
108
|
+
|
|
109
|
+
@mock.patch("code_aide.package_managers.shutil.which")
|
|
110
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="FreeBSD")
|
|
111
|
+
def test_detects_pkg_on_freebsd(self, _mock_sys, mock_which):
|
|
112
|
+
mock_which.side_effect = lambda cmd: "/usr/sbin/pkg" if cmd == "pkg" else None
|
|
113
|
+
result = detect_package_manager()
|
|
114
|
+
self.assertIsNotNone(result)
|
|
115
|
+
self.assertEqual(result.manager, PackageManager.PKG)
|
|
116
|
+
|
|
117
|
+
@mock.patch("code_aide.package_managers.shutil.which")
|
|
118
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Darwin")
|
|
119
|
+
def test_detects_brew_on_macos(self, _mock_sys, mock_which):
|
|
120
|
+
mock_which.side_effect = lambda cmd: (
|
|
121
|
+
"/opt/homebrew/bin/brew" if cmd == "brew" else None
|
|
122
|
+
)
|
|
123
|
+
result = detect_package_manager()
|
|
124
|
+
self.assertIsNotNone(result)
|
|
125
|
+
self.assertEqual(result.manager, PackageManager.HOMEBREW)
|
|
126
|
+
|
|
127
|
+
@mock.patch("code_aide.package_managers.shutil.which")
|
|
128
|
+
@mock.patch("code_aide.package_managers.platform.system", return_value="Linux")
|
|
129
|
+
def test_returns_first_match(self, _mock_sys, mock_which):
|
|
130
|
+
# apt-get is first in the list
|
|
131
|
+
mock_which.side_effect = lambda cmd: f"/usr/bin/{cmd}"
|
|
132
|
+
result = detect_package_manager()
|
|
133
|
+
self.assertEqual(result.manager, PackageManager.APT)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestParsePackageName(unittest.TestCase):
|
|
137
|
+
"""Tests for _parse_package_name output parsing."""
|
|
138
|
+
|
|
139
|
+
def test_apt_dpkg_output(self):
|
|
140
|
+
self.assertEqual(
|
|
141
|
+
_parse_package_name(PackageManager.APT, "libfoo:amd64: /usr/lib/libfoo.so"),
|
|
142
|
+
"libfoo",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def test_apt_simple_output(self):
|
|
146
|
+
self.assertEqual(
|
|
147
|
+
_parse_package_name(PackageManager.APT, "claude-code: /usr/bin/claude"),
|
|
148
|
+
"claude-code",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def test_dnf_rpm_output(self):
|
|
152
|
+
self.assertEqual(
|
|
153
|
+
_parse_package_name(PackageManager.DNF, "claude-code-2.1.50-1.x86_64"),
|
|
154
|
+
"claude-code-2.1.50-1.x86_64",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_pacman_output(self):
|
|
158
|
+
self.assertEqual(
|
|
159
|
+
_parse_package_name(
|
|
160
|
+
PackageManager.PACMAN,
|
|
161
|
+
"/usr/bin/claude is owned by claude-code 2.1.50",
|
|
162
|
+
),
|
|
163
|
+
"claude-code",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def test_pacman_no_match(self):
|
|
167
|
+
self.assertIsNone(
|
|
168
|
+
_parse_package_name(PackageManager.PACMAN, "error: no package"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def test_emerge_output(self):
|
|
172
|
+
self.assertEqual(
|
|
173
|
+
_parse_package_name(PackageManager.EMERGE, "dev-util/claude-code"),
|
|
174
|
+
"dev-util/claude-code",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def test_pkg_output(self):
|
|
178
|
+
self.assertEqual(
|
|
179
|
+
_parse_package_name(PackageManager.PKG, "claude-code-2.1.50"),
|
|
180
|
+
"claude-code-2.1.50",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def test_brew_output(self):
|
|
184
|
+
self.assertEqual(
|
|
185
|
+
_parse_package_name(PackageManager.HOMEBREW, "gemini-cli"),
|
|
186
|
+
"gemini-cli",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def test_empty_output(self):
|
|
190
|
+
self.assertIsNone(_parse_package_name(PackageManager.APT, ""))
|
|
191
|
+
self.assertIsNone(_parse_package_name(PackageManager.APT, "\n"))
|
|
192
|
+
|
|
193
|
+
def test_multiline_takes_first(self):
|
|
194
|
+
self.assertEqual(
|
|
195
|
+
_parse_package_name(PackageManager.EMERGE, "dev-util/foo\ndev-util/bar"),
|
|
196
|
+
"dev-util/foo",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TestQueryPackageOwner(unittest.TestCase):
|
|
201
|
+
"""Tests for query_package_owner."""
|
|
202
|
+
|
|
203
|
+
@mock.patch("code_aide.package_managers.detect_package_manager", return_value=None)
|
|
204
|
+
def test_returns_none_when_no_manager(self, _mock):
|
|
205
|
+
pkg, cmd = query_package_owner("/usr/bin/example")
|
|
206
|
+
self.assertIsNone(pkg)
|
|
207
|
+
self.assertIsNone(cmd)
|
|
208
|
+
|
|
209
|
+
@mock.patch("code_aide.package_managers.subprocess.run")
|
|
210
|
+
@mock.patch("code_aide.package_managers.detect_package_manager")
|
|
211
|
+
def test_emerge_query(self, mock_detect, mock_run):
|
|
212
|
+
mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.EMERGE]
|
|
213
|
+
mock_run.return_value = subprocess.CompletedProcess(
|
|
214
|
+
args=[], returncode=0, stdout="dev-util/claude-code\n"
|
|
215
|
+
)
|
|
216
|
+
pkg, cmd = query_package_owner("/opt/bin/claude")
|
|
217
|
+
self.assertEqual(pkg, "dev-util/claude-code")
|
|
218
|
+
self.assertEqual(cmd, "sudo emerge --unmerge dev-util/claude-code")
|
|
219
|
+
mock_run.assert_called_once_with(
|
|
220
|
+
["qfile", "-qC", "/opt/bin/claude"],
|
|
221
|
+
capture_output=True,
|
|
222
|
+
text=True,
|
|
223
|
+
timeout=10,
|
|
224
|
+
check=False,
|
|
225
|
+
stdin=subprocess.DEVNULL,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
@mock.patch("code_aide.package_managers.subprocess.run")
|
|
229
|
+
@mock.patch("code_aide.package_managers.detect_package_manager")
|
|
230
|
+
def test_apt_query(self, mock_detect, mock_run):
|
|
231
|
+
mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
|
|
232
|
+
mock_run.return_value = subprocess.CompletedProcess(
|
|
233
|
+
args=[], returncode=0, stdout="claude-code: /usr/bin/claude\n"
|
|
234
|
+
)
|
|
235
|
+
pkg, cmd = query_package_owner("/usr/bin/claude")
|
|
236
|
+
self.assertEqual(pkg, "claude-code")
|
|
237
|
+
self.assertEqual(cmd, "sudo apt-get remove claude-code")
|
|
238
|
+
|
|
239
|
+
@mock.patch("code_aide.package_managers.subprocess.run")
|
|
240
|
+
@mock.patch("code_aide.package_managers.detect_package_manager")
|
|
241
|
+
def test_brew_uses_basename(self, mock_detect, mock_run):
|
|
242
|
+
mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.HOMEBREW]
|
|
243
|
+
mock_run.return_value = subprocess.CompletedProcess(
|
|
244
|
+
args=[], returncode=0, stdout="gemini-cli\n"
|
|
245
|
+
)
|
|
246
|
+
pkg, cmd = query_package_owner("/opt/homebrew/bin/gemini")
|
|
247
|
+
self.assertEqual(pkg, "gemini-cli")
|
|
248
|
+
self.assertEqual(cmd, "brew uninstall gemini-cli")
|
|
249
|
+
# Verify basename was passed, not full path
|
|
250
|
+
mock_run.assert_called_once_with(
|
|
251
|
+
["brew", "which-formula", "gemini"],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
text=True,
|
|
254
|
+
timeout=10,
|
|
255
|
+
check=False,
|
|
256
|
+
stdin=subprocess.DEVNULL,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
@mock.patch("code_aide.package_managers.subprocess.run")
|
|
260
|
+
@mock.patch("code_aide.package_managers.detect_package_manager")
|
|
261
|
+
def test_returns_none_on_query_failure(self, mock_detect, mock_run):
|
|
262
|
+
mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
|
|
263
|
+
mock_run.return_value = subprocess.CompletedProcess(
|
|
264
|
+
args=[], returncode=1, stdout=""
|
|
265
|
+
)
|
|
266
|
+
pkg, cmd = query_package_owner("/usr/bin/example")
|
|
267
|
+
self.assertIsNone(pkg)
|
|
268
|
+
self.assertIsNone(cmd)
|
|
269
|
+
|
|
270
|
+
@mock.patch("code_aide.package_managers.subprocess.run")
|
|
271
|
+
@mock.patch("code_aide.package_managers.detect_package_manager")
|
|
272
|
+
def test_returns_none_on_exception(self, mock_detect, mock_run):
|
|
273
|
+
mock_detect.return_value = PACKAGE_MANAGER_BY_ENUM[PackageManager.APT]
|
|
274
|
+
mock_run.side_effect = OSError("timeout")
|
|
275
|
+
pkg, cmd = query_package_owner("/usr/bin/example")
|
|
276
|
+
self.assertIsNone(pkg)
|
|
277
|
+
self.assertIsNone(cmd)
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""Shared constants and mutable tool configuration for CLI modules."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
from typing import Any, Dict
|
|
6
|
-
|
|
7
|
-
from code_aide.config import load_tools_config
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _use_color() -> bool:
|
|
11
|
-
"""Determine whether to emit ANSI color codes.
|
|
12
|
-
|
|
13
|
-
Checks (in priority order):
|
|
14
|
-
NO_COLOR — if set (any value), no color (https://no-color.org/)
|
|
15
|
-
FORCE_COLOR — if set (any value), force color
|
|
16
|
-
CLICOLOR_FORCE — if non-empty, force color
|
|
17
|
-
TERM=dumb — no color
|
|
18
|
-
CLICOLOR=0 — no color
|
|
19
|
-
stdout is not a TTY — no color
|
|
20
|
-
"""
|
|
21
|
-
if os.environ.get("NO_COLOR") is not None:
|
|
22
|
-
return False
|
|
23
|
-
if os.environ.get("FORCE_COLOR") is not None:
|
|
24
|
-
return True
|
|
25
|
-
if os.environ.get("CLICOLOR_FORCE", ""):
|
|
26
|
-
return True
|
|
27
|
-
if os.environ.get("TERM") == "dumb":
|
|
28
|
-
return False
|
|
29
|
-
if os.environ.get("CLICOLOR") == "0":
|
|
30
|
-
return False
|
|
31
|
-
return sys.stdout.isatty()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class Colors:
|
|
35
|
-
if _use_color():
|
|
36
|
-
RED = "\033[0;31m"
|
|
37
|
-
GREEN = "\033[0;32m"
|
|
38
|
-
YELLOW = "\033[1;33m"
|
|
39
|
-
BLUE = "\033[0;34m"
|
|
40
|
-
BOLD = "\033[1m"
|
|
41
|
-
NC = "\033[0m"
|
|
42
|
-
else:
|
|
43
|
-
RED = ""
|
|
44
|
-
GREEN = ""
|
|
45
|
-
YELLOW = ""
|
|
46
|
-
BLUE = ""
|
|
47
|
-
BOLD = ""
|
|
48
|
-
NC = ""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
PACKAGE_MANAGERS: Dict[str, Dict[str, Any]] = {
|
|
52
|
-
"apt-get": {
|
|
53
|
-
"detect_command": "apt-get",
|
|
54
|
-
"packages": ["nodejs", "npm"],
|
|
55
|
-
"pre_install": [["sudo", "apt-get", "update"]],
|
|
56
|
-
"install_command": ["sudo", "apt-get", "install", "-y"],
|
|
57
|
-
"description": "Debian/Ubuntu",
|
|
58
|
-
},
|
|
59
|
-
"dnf": {
|
|
60
|
-
"detect_command": "dnf",
|
|
61
|
-
"packages": ["nodejs", "npm"],
|
|
62
|
-
"pre_install": [],
|
|
63
|
-
"install_command": ["sudo", "dnf", "install", "-y"],
|
|
64
|
-
"description": "Fedora/RHEL 8+",
|
|
65
|
-
},
|
|
66
|
-
"yum": {
|
|
67
|
-
"detect_command": "yum",
|
|
68
|
-
"packages": ["nodejs", "npm"],
|
|
69
|
-
"pre_install": [],
|
|
70
|
-
"install_command": ["sudo", "yum", "install", "-y"],
|
|
71
|
-
"description": "RHEL/CentOS 7",
|
|
72
|
-
},
|
|
73
|
-
"pacman": {
|
|
74
|
-
"detect_command": "pacman",
|
|
75
|
-
"packages": ["nodejs", "npm"],
|
|
76
|
-
"pre_install": [],
|
|
77
|
-
"install_command": ["sudo", "pacman", "-S", "--noconfirm"],
|
|
78
|
-
"description": "Arch Linux",
|
|
79
|
-
},
|
|
80
|
-
"zypper": {
|
|
81
|
-
"detect_command": "zypper",
|
|
82
|
-
"packages": ["nodejs", "npm"],
|
|
83
|
-
"pre_install": [],
|
|
84
|
-
"install_command": ["sudo", "zypper", "install", "-y"],
|
|
85
|
-
"description": "openSUSE",
|
|
86
|
-
},
|
|
87
|
-
"emerge": {
|
|
88
|
-
"detect_command": "emerge",
|
|
89
|
-
"packages": ["net-libs/nodejs"],
|
|
90
|
-
"pre_install": [],
|
|
91
|
-
"install_command": ["sudo", "emerge", "--quiet-build"],
|
|
92
|
-
"description": "Gentoo",
|
|
93
|
-
},
|
|
94
|
-
"pkg": {
|
|
95
|
-
"detect_command": "pkg",
|
|
96
|
-
"packages": ["node22", "npm-node22"],
|
|
97
|
-
"pre_install": [],
|
|
98
|
-
"install_command": ["sudo", "pkg", "install", "-y"],
|
|
99
|
-
"description": "FreeBSD",
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
TOOLS: Dict[str, Dict[str, Any]] = load_tools_config()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{code_aide-1.12.3 → code_aide-1.14.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|