code-aide 1.18.3__tar.gz → 1.19.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.18.3 → code_aide-1.19.0}/PKG-INFO +19 -1
- {code_aide-1.18.3 → code_aide-1.19.0}/README.md +18 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/amp-install.sh +0 -30
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/__init__.py +1 -1
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/console.py +32 -11
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/data/tools.json +1 -1
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/install.py +16 -7
- code_aide-1.19.0/src/code_aide/npm_cache.py +61 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_install.py +14 -12
- code_aide-1.19.0/tests/test_npm_cache.py +204 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/.github/workflows/ci.yml +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/.github/workflows/publish.yml +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/.gitignore +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/.gitlab-ci.yml +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/.pre-commit-config.yaml +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/AGENTS.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/CLAUDE.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/LICENSE +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/Makefile +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/TODO.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/pyproject.toml +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/README.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/antigravity-install.sh +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/claude-install.sh +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/cursor-install.sh +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/2026-05-03-qwen3.6-code-quality-analysis.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/antigravity-cli-integration.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/auto-migrate-deprecated-installs.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/claude-native-installer-migration.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/missing-coding-llm-cli-tools.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/pre-commit-uv-setup.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/remove-bundled-version-baseline.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/__main__.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/commands_actions.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/commands_tools.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/config.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/constants.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/detection.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/detection_types.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/entry.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/install_types.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/operation_handlers.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/operations.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/package_info.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/package_managers.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/prereqs.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/status.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/status_print.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/versions.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_commands_actions.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_commands_tools.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_config.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_console.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_constants.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_detection.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_entry.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_error_paths.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_install_types.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_operations.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_package_info.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_package_managers.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_prereqs.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_status.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_versions.py +0 -0
- {code_aide-1.18.3 → code_aide-1.19.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: code-aide
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.19.0
|
|
4
4
|
Summary: Manage AI coding CLI tools (Claude, Antigravity, 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
|
|
@@ -234,3 +234,21 @@ uv run pytest tests/test_install.py::TestDetectOsArch -v
|
|
|
234
234
|
## License
|
|
235
235
|
|
|
236
236
|
Apache-2.0
|
|
237
|
+
|
|
238
|
+
## npm download cache lifetime
|
|
239
|
+
|
|
240
|
+
Standalone install, upgrade, removal (including migration), and installation
|
|
241
|
+
scripts use private download caches under
|
|
242
|
+
`~/.cache/code-aide/npm-operations/operation-*`. Each invocation gets its own
|
|
243
|
+
directory on the home filesystem instead of the temporary filesystem. npm
|
|
244
|
+
authentication, user configuration and install prefixes remain in effect; only
|
|
245
|
+
the download-cache setting is overridden in the child environment.
|
|
246
|
+
|
|
247
|
+
Downloads and npm logs in that directory are removed after success, failure,
|
|
248
|
+
timeouts supported by the command runner, and normal Python interruption.
|
|
249
|
+
Captured command errors are still reported. Existing user caches are preserved.
|
|
250
|
+
Dry runs create no operation cache. Concurrent operations never clean another
|
|
251
|
+
operation's cache. A hard kill or machine crash can leave an operation
|
|
252
|
+
directory; inspect it and establish that its operation has stopped before
|
|
253
|
+
explicitly removing it. There is no automatic age-based cleanup of these
|
|
254
|
+
directories.
|
|
@@ -207,3 +207,21 @@ uv run pytest tests/test_install.py::TestDetectOsArch -v
|
|
|
207
207
|
## License
|
|
208
208
|
|
|
209
209
|
Apache-2.0
|
|
210
|
+
|
|
211
|
+
## npm download cache lifetime
|
|
212
|
+
|
|
213
|
+
Standalone install, upgrade, removal (including migration), and installation
|
|
214
|
+
scripts use private download caches under
|
|
215
|
+
`~/.cache/code-aide/npm-operations/operation-*`. Each invocation gets its own
|
|
216
|
+
directory on the home filesystem instead of the temporary filesystem. npm
|
|
217
|
+
authentication, user configuration and install prefixes remain in effect; only
|
|
218
|
+
the download-cache setting is overridden in the child environment.
|
|
219
|
+
|
|
220
|
+
Downloads and npm logs in that directory are removed after success, failure,
|
|
221
|
+
timeouts supported by the command runner, and normal Python interruption.
|
|
222
|
+
Captured command errors are still reported. Existing user caches are preserved.
|
|
223
|
+
Dry runs create no operation cache. Concurrent operations never clean another
|
|
224
|
+
operation's cache. A hard kill or machine crash can leave an operation
|
|
225
|
+
directory; inspect it and establish that its operation has stopped before
|
|
226
|
+
explicitly removing it. There is no automatic age-based cleanup of these
|
|
227
|
+
directories.
|
|
@@ -265,31 +265,6 @@ verify_checksum() {
|
|
|
265
265
|
success "Checksum verified"
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
# Verify signature using minisign
|
|
269
|
-
verify_signature() {
|
|
270
|
-
local file="$1"
|
|
271
|
-
local signature_url="$2"
|
|
272
|
-
|
|
273
|
-
if ! command_exists minisign; then
|
|
274
|
-
log "minisign not installed, skipping signature verification"
|
|
275
|
-
return 0
|
|
276
|
-
fi
|
|
277
|
-
|
|
278
|
-
local signature_path="$AMP_HOME/amp-install-signature.minisign"
|
|
279
|
-
local pubkey_path="$AMP_HOME/signing-key.pub"
|
|
280
|
-
|
|
281
|
-
download_file "$signature_url" "$signature_path" 'signature'
|
|
282
|
-
download_file "$AMP_URL/.well-known/signing-key.pub" "$pubkey_path" 'signing key'
|
|
283
|
-
|
|
284
|
-
if ! minisign -Vm "$file" -x "$signature_path" -p "$pubkey_path" >/dev/null 2>&1; then
|
|
285
|
-
rm -f "$signature_path" "$pubkey_path"
|
|
286
|
-
error "Signature verification failed! The binary may have been tampered with."
|
|
287
|
-
fi
|
|
288
|
-
|
|
289
|
-
rm -f "$signature_path" "$pubkey_path"
|
|
290
|
-
success "Signature verified"
|
|
291
|
-
}
|
|
292
|
-
|
|
293
268
|
# Fetch latest CLI version
|
|
294
269
|
fetch_latest_version() {
|
|
295
270
|
local version_url="$AMP_STORAGE_BASE/cli/cli-version.txt"
|
|
@@ -324,7 +299,6 @@ install_amp_binary() {
|
|
|
324
299
|
|
|
325
300
|
local binary_url="$AMP_STORAGE_BASE/cli/${version}/amp-${platform}"
|
|
326
301
|
local checksum_url="$AMP_STORAGE_BASE/cli/${version}/${platform}-amp.sha256"
|
|
327
|
-
local minisign_signature_url="$binary_url.minisig"
|
|
328
302
|
|
|
329
303
|
# Add .exe for Windows downloads
|
|
330
304
|
if [[ "$platform" == *"windows"* ]]; then
|
|
@@ -352,10 +326,6 @@ install_amp_binary() {
|
|
|
352
326
|
# Verify checksum
|
|
353
327
|
verify_checksum "$staged_binary_path" "$expected_checksum"
|
|
354
328
|
|
|
355
|
-
# Verify signature (optional, only if minisign is installed)
|
|
356
|
-
# Disabled until release signing is enabled
|
|
357
|
-
# verify_signature "$staged_binary_path" "$minisign_signature_url"
|
|
358
|
-
|
|
359
329
|
# Make executable before atomically replacing the installed binary
|
|
360
330
|
chmod 0755 "$staged_binary_path"
|
|
361
331
|
mv "$staged_binary_path" "$binary_path"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Console output and subprocess helpers for CLI modules."""
|
|
2
2
|
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from pathlib import Path
|
|
3
5
|
import shutil
|
|
4
6
|
import subprocess
|
|
5
|
-
from typing import List
|
|
7
|
+
from typing import Any, List
|
|
6
8
|
|
|
7
9
|
from code_aide.constants import Colors
|
|
10
|
+
from code_aide.npm_cache import npm_cache_environment, run_npm_process
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
def info(message: str) -> None:
|
|
@@ -33,7 +36,10 @@ def command_exists(command: str) -> bool:
|
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
def run_command(
|
|
36
|
-
cmd: List[str],
|
|
39
|
+
cmd: List[str],
|
|
40
|
+
check: bool = True,
|
|
41
|
+
capture: bool = True,
|
|
42
|
+
timeout: float | None = None,
|
|
37
43
|
) -> subprocess.CompletedProcess:
|
|
38
44
|
"""Run a tool-mutating command with consistent hardening.
|
|
39
45
|
|
|
@@ -47,19 +53,34 @@ def run_command(
|
|
|
47
53
|
Defaults to capture+text mode. With ``check=True`` (default) a
|
|
48
54
|
non-zero exit raises ``CalledProcessError``.
|
|
49
55
|
"""
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
is_npm = bool(cmd) and Path(cmd[0]).name == "npm"
|
|
57
|
+
if is_npm and any(
|
|
58
|
+
arg == "--cache" or arg.startswith("--cache=") for arg in cmd[1:]
|
|
59
|
+
):
|
|
60
|
+
raise ValueError("npm cache overrides are managed by code-aide")
|
|
61
|
+
context = npm_cache_environment() if is_npm else nullcontext(None)
|
|
62
|
+
with context as env:
|
|
63
|
+
kwargs: dict[str, Any] = {}
|
|
64
|
+
if env is not None:
|
|
65
|
+
kwargs["env"] = env
|
|
66
|
+
if timeout is not None:
|
|
67
|
+
kwargs["timeout"] = timeout
|
|
68
|
+
runner = run_npm_process if is_npm else subprocess.run
|
|
69
|
+
if capture:
|
|
70
|
+
return runner(
|
|
71
|
+
cmd,
|
|
72
|
+
check=check,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
stdin=subprocess.DEVNULL,
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
return runner(
|
|
52
79
|
cmd,
|
|
53
80
|
check=check,
|
|
54
|
-
capture_output=True,
|
|
55
|
-
text=True,
|
|
56
81
|
stdin=subprocess.DEVNULL,
|
|
82
|
+
**kwargs,
|
|
57
83
|
)
|
|
58
|
-
return subprocess.run(
|
|
59
|
-
cmd,
|
|
60
|
-
check=check,
|
|
61
|
-
stdin=subprocess.DEVNULL,
|
|
62
|
-
)
|
|
63
84
|
|
|
64
85
|
|
|
65
86
|
def called_process_error_message(exc: subprocess.CalledProcessError) -> str:
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
"command": "amp",
|
|
90
90
|
"install_type": "script",
|
|
91
91
|
"install_url": "https://ampcode.com/install.sh",
|
|
92
|
-
"install_sha256": "
|
|
92
|
+
"install_sha256": "83b0a61b09cb653b92e47e039e3aba8bda60c62c0eea6e1467c1b1b12c258cc9",
|
|
93
93
|
"install_script_path_prepend": [
|
|
94
94
|
"~/.local/bin"
|
|
95
95
|
],
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import platform
|
|
7
7
|
import shutil
|
|
8
8
|
import subprocess
|
|
9
|
+
|
|
9
10
|
import tarfile
|
|
10
11
|
import tempfile
|
|
11
12
|
from dataclasses import dataclass
|
|
@@ -21,6 +22,7 @@ from code_aide.console import (
|
|
|
21
22
|
success,
|
|
22
23
|
warning,
|
|
23
24
|
)
|
|
25
|
+
from code_aide.npm_cache import npm_cache_environment, stop_process_group
|
|
24
26
|
from code_aide.install_types import InstallType, get_tool_install_type
|
|
25
27
|
from code_aide.versions import check_script_tool, fetch_url
|
|
26
28
|
|
|
@@ -113,13 +115,20 @@ def run_install_script(
|
|
|
113
115
|
warning(f"About to execute install script for {tool_name}")
|
|
114
116
|
warning("This will run with your user privileges. Press Ctrl+C to cancel.")
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
# Scripts may invoke npm themselves; inherit the same bounded cache.
|
|
119
|
+
with npm_cache_environment(env) as script_env:
|
|
120
|
+
bash_process = subprocess.Popen(
|
|
121
|
+
["bash"],
|
|
122
|
+
stdin=subprocess.PIPE,
|
|
123
|
+
stderr=subprocess.PIPE,
|
|
124
|
+
env=script_env,
|
|
125
|
+
start_new_session=True,
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
_, stderr = bash_process.communicate(input=script_content)
|
|
129
|
+
except BaseException:
|
|
130
|
+
stop_process_group(bash_process)
|
|
131
|
+
raise
|
|
123
132
|
|
|
124
133
|
if bash_process.returncode == 0:
|
|
125
134
|
return True
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Private download-cache lifetimes for npm commands and installer scripts."""
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any, Iterator, Mapping
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def npm_cache_environment(
|
|
14
|
+
env: Mapping[str, str] | None = None,
|
|
15
|
+
) -> Iterator[dict[str, str]]:
|
|
16
|
+
"""Keep downloads on the home filesystem and remove only this invocation."""
|
|
17
|
+
root = Path.home() / ".cache" / "code-aide" / "npm-operations"
|
|
18
|
+
current = Path.home()
|
|
19
|
+
for component in (".cache", "code-aide", "npm-operations"):
|
|
20
|
+
current = current / component
|
|
21
|
+
if current.is_symlink():
|
|
22
|
+
raise ValueError(f"npm operation cache parent is a symlink: {current}")
|
|
23
|
+
current.mkdir(exist_ok=True, mode=0o700)
|
|
24
|
+
if current.stat().st_uid != os.getuid():
|
|
25
|
+
raise ValueError(f"npm operation cache parent has another owner: {current}")
|
|
26
|
+
with tempfile.TemporaryDirectory(prefix="operation-", dir=root) as directory:
|
|
27
|
+
child_env = dict(os.environ if env is None else env)
|
|
28
|
+
# npm treats config environment keys case-insensitively.
|
|
29
|
+
for key in list(child_env):
|
|
30
|
+
if key.lower() == "npm_config_cache":
|
|
31
|
+
del child_env[key]
|
|
32
|
+
child_env["npm_config_cache"] = directory
|
|
33
|
+
yield child_env
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def stop_process_group(process: subprocess.Popen) -> None:
|
|
37
|
+
"""Stop the entire private process group before its cache is removed."""
|
|
38
|
+
try:
|
|
39
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
40
|
+
except ProcessLookupError:
|
|
41
|
+
pass
|
|
42
|
+
process.communicate(timeout=5)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_npm_process(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess:
|
|
46
|
+
"""Run npm in its own session and reap its children on cancellation."""
|
|
47
|
+
check = kwargs.pop("check", True)
|
|
48
|
+
timeout = kwargs.pop("timeout", None)
|
|
49
|
+
if kwargs.pop("capture_output", False):
|
|
50
|
+
kwargs["stdout"] = subprocess.PIPE
|
|
51
|
+
kwargs["stderr"] = subprocess.PIPE
|
|
52
|
+
with subprocess.Popen(cmd, start_new_session=True, **kwargs) as process:
|
|
53
|
+
try:
|
|
54
|
+
stdout, stderr = process.communicate(timeout=timeout)
|
|
55
|
+
except BaseException:
|
|
56
|
+
stop_process_group(process)
|
|
57
|
+
raise
|
|
58
|
+
result = subprocess.CompletedProcess(cmd, process.returncode, stdout, stderr)
|
|
59
|
+
if check:
|
|
60
|
+
result.check_returncode()
|
|
61
|
+
return result
|
|
@@ -200,20 +200,22 @@ class TestInstallScriptEnv(unittest.TestCase):
|
|
|
200
200
|
mock_fetch.return_value = (script_content, None)
|
|
201
201
|
env = {"PATH": "/tmp/bin:/usr/bin"}
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
203
|
+
with tempfile.TemporaryDirectory() as home:
|
|
204
|
+
with mock.patch.dict(os.environ, {"HOME": home}):
|
|
205
|
+
result = cli_install.run_install_script(
|
|
206
|
+
"https://example.com/install.sh",
|
|
207
|
+
"Example",
|
|
208
|
+
expected_sha256,
|
|
209
|
+
env=env,
|
|
210
|
+
)
|
|
209
211
|
|
|
210
212
|
self.assertTrue(result)
|
|
211
|
-
mock_popen.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)
|
|
213
|
+
mock_popen.assert_called_once()
|
|
214
|
+
actual_env = mock_popen.call_args.kwargs["env"]
|
|
215
|
+
self.assertEqual(actual_env["PATH"], env["PATH"])
|
|
216
|
+
self.assertIn("npm_config_cache", actual_env)
|
|
217
|
+
self.assertFalse(os.path.exists(actual_env["npm_config_cache"]))
|
|
218
|
+
self.assertEqual(env, {"PATH": "/tmp/bin:/usr/bin"})
|
|
217
219
|
|
|
218
220
|
|
|
219
221
|
class TestInstallDirectDownloadDryrun(unittest.TestCase):
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Cache lifetime regressions through standalone operation dispatch."""
|
|
2
|
+
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from code_aide import console, constants, install, operations
|
|
12
|
+
from code_aide.npm_cache import npm_cache_environment
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def cache_home(tmp_path, monkeypatch):
|
|
17
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
18
|
+
return tmp_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.parametrize("route", ["install", "upgrade", "remove"])
|
|
22
|
+
@pytest.mark.parametrize("fail", [False, True])
|
|
23
|
+
def test_operation_downloads_removed(cache_home, monkeypatch, route, fail):
|
|
24
|
+
user_cache = cache_home / "existing-cache"
|
|
25
|
+
user_cache.mkdir()
|
|
26
|
+
marker = user_cache / "keep"
|
|
27
|
+
marker.write_text("existing")
|
|
28
|
+
monkeypatch.setenv("NPM_CONFIG_CACHE", str(user_cache))
|
|
29
|
+
monkeypatch.setenv("npm_config_prefix", "/example/prefix")
|
|
30
|
+
monkeypatch.setenv("NPM_TOKEN", "fake-token")
|
|
31
|
+
original = os.environ.copy()
|
|
32
|
+
seen = []
|
|
33
|
+
|
|
34
|
+
def run(cmd, **kwargs):
|
|
35
|
+
env = kwargs["env"]
|
|
36
|
+
cache = Path(env["npm_config_cache"])
|
|
37
|
+
assert cache.stat().st_mode & 0o777 == 0o700
|
|
38
|
+
assert "NPM_CONFIG_CACHE" not in env
|
|
39
|
+
assert env["npm_config_prefix"] == "/example/prefix"
|
|
40
|
+
assert env["NPM_TOKEN"] == "fake-token"
|
|
41
|
+
(cache / "download").write_bytes(b"synthetic tarball")
|
|
42
|
+
seen.append(cache)
|
|
43
|
+
if fail:
|
|
44
|
+
raise subprocess.CalledProcessError(1, cmd, stderr="fake failure")
|
|
45
|
+
return subprocess.CompletedProcess(cmd, 0, stdout="/example/prefix")
|
|
46
|
+
|
|
47
|
+
cfg = dict(
|
|
48
|
+
name="Fake Tool",
|
|
49
|
+
command="fake-tool",
|
|
50
|
+
install_type="npm",
|
|
51
|
+
npm_package="fake-tool",
|
|
52
|
+
next_steps="Run fake-tool",
|
|
53
|
+
)
|
|
54
|
+
monkeypatch.setitem(constants._TOOLS_DATA, "fake", cfg)
|
|
55
|
+
monkeypatch.setattr(console, "run_npm_process", run)
|
|
56
|
+
monkeypatch.setattr(install, "command_exists", lambda _: False)
|
|
57
|
+
monkeypatch.setattr(install.platform, "system", lambda: "Linux")
|
|
58
|
+
monkeypatch.setattr(operations, "is_tool_installed", lambda _: True)
|
|
59
|
+
monkeypatch.setattr(operations, "is_deprecated_install", lambda _: False)
|
|
60
|
+
monkeypatch.setattr(
|
|
61
|
+
operations,
|
|
62
|
+
"detect_install_method",
|
|
63
|
+
lambda _: {"method": "npm", "detail": "fake-tool"},
|
|
64
|
+
)
|
|
65
|
+
monkeypatch.setattr(operations, "_get_upgrade_snapshot", lambda *a: {})
|
|
66
|
+
monkeypatch.setattr(
|
|
67
|
+
operations,
|
|
68
|
+
"_upgrade_result_from_snapshots",
|
|
69
|
+
lambda *a: operations.UpgradeResult.CHANGED,
|
|
70
|
+
)
|
|
71
|
+
if route == "install":
|
|
72
|
+
assert bool(install.install_tool("fake")) is not fail
|
|
73
|
+
elif route == "upgrade":
|
|
74
|
+
result = operations.upgrade_tool("fake")
|
|
75
|
+
assert (result == operations.UpgradeResult.FAILED) is fail
|
|
76
|
+
else:
|
|
77
|
+
assert operations.remove_tool("fake") is not fail
|
|
78
|
+
assert seen and all(not p.exists() for p in seen)
|
|
79
|
+
assert marker.read_text() == "existing"
|
|
80
|
+
assert os.environ == original
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_concurrent_operations_keep_each_others_downloads(cache_home):
|
|
84
|
+
barrier = threading.Barrier(2)
|
|
85
|
+
|
|
86
|
+
def operation(_):
|
|
87
|
+
with npm_cache_environment() as env:
|
|
88
|
+
path = Path(env["npm_config_cache"])
|
|
89
|
+
(path / "download").write_text("data")
|
|
90
|
+
barrier.wait(timeout=5)
|
|
91
|
+
assert len(list(path.parent.iterdir())) == 2
|
|
92
|
+
barrier.wait(timeout=5)
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
96
|
+
paths = list(pool.map(operation, range(2)))
|
|
97
|
+
assert paths[0] != paths[1]
|
|
98
|
+
assert all(not path.exists() for path in paths)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.parametrize("exception", [KeyboardInterrupt, subprocess.TimeoutExpired])
|
|
102
|
+
def test_exception_cleanup(cache_home, monkeypatch, exception):
|
|
103
|
+
seen = []
|
|
104
|
+
|
|
105
|
+
def run(cmd, **kwargs):
|
|
106
|
+
seen.append(Path(kwargs["env"]["npm_config_cache"]))
|
|
107
|
+
if exception is subprocess.TimeoutExpired:
|
|
108
|
+
raise exception(cmd, 0.01)
|
|
109
|
+
raise exception()
|
|
110
|
+
|
|
111
|
+
monkeypatch.setattr(console, "run_npm_process", run)
|
|
112
|
+
with pytest.raises(exception):
|
|
113
|
+
console.run_command(["npm", "install", "fake"], timeout=0.01)
|
|
114
|
+
assert not seen[0].exists()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_non_npm_and_dryrun_do_not_create_cache(cache_home, monkeypatch):
|
|
118
|
+
monkeypatch.setattr(
|
|
119
|
+
console.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0)
|
|
120
|
+
)
|
|
121
|
+
console.run_command(["example", "install"])
|
|
122
|
+
monkeypatch.setattr(install, "command_exists", lambda _: False)
|
|
123
|
+
monkeypatch.setattr(install.platform, "system", lambda: "Linux")
|
|
124
|
+
assert install.install_tool("codex", dryrun=True)
|
|
125
|
+
assert not (cache_home / ".cache").exists()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize("returncode", [0, 1])
|
|
129
|
+
def test_script_downloads_removed(cache_home, monkeypatch, returncode):
|
|
130
|
+
# Real bash exercises an installer subprocess rather than mocking its env.
|
|
131
|
+
fake_bin = cache_home / "bin"
|
|
132
|
+
fake_bin.mkdir()
|
|
133
|
+
fake_npm = fake_bin / "npm"
|
|
134
|
+
fake_npm.write_text(
|
|
135
|
+
"#!/bin/sh\n"
|
|
136
|
+
'mkdir -p "$npm_config_cache/nested"\n'
|
|
137
|
+
'printf download > "$npm_config_cache/nested/archive"\n'
|
|
138
|
+
'printf "%s" "$npm_config_cache" > "$HOME/observed-cache"\n'
|
|
139
|
+
f"exit {returncode}\n"
|
|
140
|
+
)
|
|
141
|
+
fake_npm.chmod(0o755)
|
|
142
|
+
monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"])
|
|
143
|
+
script = b"npm install -g fake-tool\n"
|
|
144
|
+
monkeypatch.setattr(install, "fetch_url", lambda _: (script, None))
|
|
145
|
+
assert install.run_install_script("https://example.invalid", "Fake") is (
|
|
146
|
+
returncode == 0
|
|
147
|
+
)
|
|
148
|
+
observed = Path((cache_home / "observed-cache").read_text())
|
|
149
|
+
assert not observed.exists()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_explicit_cache_override_rejected_before_cache_creation(cache_home):
|
|
153
|
+
with pytest.raises(ValueError, match="cache overrides"):
|
|
154
|
+
console.run_command(["npm", "install", "--cache=/example"])
|
|
155
|
+
assert not (cache_home / ".cache").exists()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_symlink_cache_parent_rejected(cache_home, tmp_path):
|
|
159
|
+
target = tmp_path / "outside"
|
|
160
|
+
target.mkdir(mode=0o755)
|
|
161
|
+
(cache_home / ".cache").symlink_to(target)
|
|
162
|
+
with pytest.raises(ValueError, match="symlink"):
|
|
163
|
+
with npm_cache_environment():
|
|
164
|
+
pytest.fail("must reject symlink")
|
|
165
|
+
assert not list(target.iterdir())
|
|
166
|
+
assert target.stat().st_mode & 0o777 == 0o755
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_real_timeout_stops_descendant_before_cleanup(cache_home, monkeypatch):
|
|
170
|
+
import sys
|
|
171
|
+
import time
|
|
172
|
+
|
|
173
|
+
fake_bin = cache_home / "bin"
|
|
174
|
+
fake_bin.mkdir()
|
|
175
|
+
cache_record = cache_home / "cache-path"
|
|
176
|
+
ready = cache_home / "ready"
|
|
177
|
+
child = cache_home / "writer.py"
|
|
178
|
+
child.write_text(
|
|
179
|
+
"import os, pathlib, time\n"
|
|
180
|
+
"p = pathlib.Path(os.environ['npm_config_cache'])\n"
|
|
181
|
+
"pathlib.Path(os.environ['HOME'], 'ready').write_text('ready')\n"
|
|
182
|
+
"while True:\n"
|
|
183
|
+
" p.mkdir(parents=True, exist_ok=True)\n"
|
|
184
|
+
" (p / 'download').write_text('data')\n"
|
|
185
|
+
" time.sleep(0.01)\n"
|
|
186
|
+
)
|
|
187
|
+
fake_npm = fake_bin / "npm"
|
|
188
|
+
fake_npm.write_text(
|
|
189
|
+
f"#!{sys.executable}\n"
|
|
190
|
+
"import os, pathlib, subprocess, sys, time\n"
|
|
191
|
+
"pathlib.Path(os.environ['HOME'], 'cache-path').write_text(os.environ['npm_config_cache'])\n"
|
|
192
|
+
f"subprocess.Popen([sys.executable, {str(child)!r}])\n"
|
|
193
|
+
"time.sleep(30)\n"
|
|
194
|
+
)
|
|
195
|
+
fake_npm.chmod(0o755)
|
|
196
|
+
monkeypatch.setenv("PATH", str(fake_bin) + os.pathsep + os.environ["PATH"])
|
|
197
|
+
with pytest.raises(subprocess.TimeoutExpired):
|
|
198
|
+
console.run_command(["npm", "install", "fake"], timeout=0.5)
|
|
199
|
+
assert ready.exists()
|
|
200
|
+
cache = Path(cache_record.read_text())
|
|
201
|
+
assert not cache.exists()
|
|
202
|
+
# The descendant would recreate its directory within 10ms if still alive.
|
|
203
|
+
time.sleep(0.1)
|
|
204
|
+
assert not cache.exists()
|
|
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.18.3 → code_aide-1.19.0}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.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
|
{code_aide-1.18.3 → code_aide-1.19.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
|
|
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
|