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.
Files changed (67) hide show
  1. {code_aide-1.18.3 → code_aide-1.19.0}/PKG-INFO +19 -1
  2. {code_aide-1.18.3 → code_aide-1.19.0}/README.md +18 -0
  3. {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/amp-install.sh +0 -30
  4. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/__init__.py +1 -1
  5. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/console.py +32 -11
  6. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/data/tools.json +1 -1
  7. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/install.py +16 -7
  8. code_aide-1.19.0/src/code_aide/npm_cache.py +61 -0
  9. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_install.py +14 -12
  10. code_aide-1.19.0/tests/test_npm_cache.py +204 -0
  11. {code_aide-1.18.3 → code_aide-1.19.0}/.github/workflows/ci.yml +0 -0
  12. {code_aide-1.18.3 → code_aide-1.19.0}/.github/workflows/publish.yml +0 -0
  13. {code_aide-1.18.3 → code_aide-1.19.0}/.gitignore +0 -0
  14. {code_aide-1.18.3 → code_aide-1.19.0}/.gitlab-ci.yml +0 -0
  15. {code_aide-1.18.3 → code_aide-1.19.0}/.pre-commit-config.yaml +0 -0
  16. {code_aide-1.18.3 → code_aide-1.19.0}/AGENTS.md +0 -0
  17. {code_aide-1.18.3 → code_aide-1.19.0}/CLAUDE.md +0 -0
  18. {code_aide-1.18.3 → code_aide-1.19.0}/LICENSE +0 -0
  19. {code_aide-1.18.3 → code_aide-1.19.0}/Makefile +0 -0
  20. {code_aide-1.18.3 → code_aide-1.19.0}/TODO.md +0 -0
  21. {code_aide-1.18.3 → code_aide-1.19.0}/pyproject.toml +0 -0
  22. {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/README.md +0 -0
  23. {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/antigravity-install.sh +0 -0
  24. {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/claude-install.sh +0 -0
  25. {code_aide-1.18.3 → code_aide-1.19.0}/script-archive/cursor-install.sh +0 -0
  26. {code_aide-1.18.3 → code_aide-1.19.0}/specs/2026-05-03-qwen3.6-code-quality-analysis-original.md +0 -0
  27. {code_aide-1.18.3 → code_aide-1.19.0}/specs/2026-05-03-qwen3.6-code-quality-analysis.md +0 -0
  28. {code_aide-1.18.3 → code_aide-1.19.0}/specs/antigravity-cli-integration.md +0 -0
  29. {code_aide-1.18.3 → code_aide-1.19.0}/specs/auto-migrate-deprecated-installs.md +0 -0
  30. {code_aide-1.18.3 → code_aide-1.19.0}/specs/claude-native-installer-migration.md +0 -0
  31. {code_aide-1.18.3 → code_aide-1.19.0}/specs/missing-coding-llm-cli-tools.md +0 -0
  32. {code_aide-1.18.3 → code_aide-1.19.0}/specs/pre-commit-uv-setup.md +0 -0
  33. {code_aide-1.18.3 → code_aide-1.19.0}/specs/remove-bundled-version-baseline.md +0 -0
  34. {code_aide-1.18.3 → code_aide-1.19.0}/specs/unify-upgrade-eligibility-with-shared-evaluator.md +0 -0
  35. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/__main__.py +0 -0
  36. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/commands_actions.py +0 -0
  37. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/commands_tools.py +0 -0
  38. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/config.py +0 -0
  39. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/constants.py +0 -0
  40. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/detection.py +0 -0
  41. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/detection_types.py +0 -0
  42. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/entry.py +0 -0
  43. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/install_types.py +0 -0
  44. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/operation_handlers.py +0 -0
  45. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/operations.py +0 -0
  46. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/package_info.py +0 -0
  47. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/package_managers.py +0 -0
  48. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/prereqs.py +0 -0
  49. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/status.py +0 -0
  50. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/status_print.py +0 -0
  51. {code_aide-1.18.3 → code_aide-1.19.0}/src/code_aide/versions.py +0 -0
  52. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_commands_actions.py +0 -0
  53. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_commands_tools.py +0 -0
  54. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_config.py +0 -0
  55. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_console.py +0 -0
  56. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_constants.py +0 -0
  57. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_detection.py +0 -0
  58. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_entry.py +0 -0
  59. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_error_paths.py +0 -0
  60. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_install_types.py +0 -0
  61. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_operations.py +0 -0
  62. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_package_info.py +0 -0
  63. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_package_managers.py +0 -0
  64. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_prereqs.py +0 -0
  65. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_status.py +0 -0
  66. {code_aide-1.18.3 → code_aide-1.19.0}/tests/test_versions.py +0 -0
  67. {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.18.3
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,3 +1,3 @@
1
1
  """code-aide - Manage AI coding CLI tools."""
2
2
 
3
- __version__ = "1.18.3"
3
+ __version__ = "1.19.0"
@@ -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], check: bool = True, capture: bool = True
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
- if capture:
51
- return subprocess.run(
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": "70f9a234fdc1aee9bf88af73c38a80435556e5dd364d204bd0ff8b6a27f23f5f",
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
- bash_process = subprocess.Popen(
117
- ["bash"],
118
- stdin=subprocess.PIPE,
119
- stderr=subprocess.PIPE,
120
- env=env,
121
- )
122
- _, stderr = bash_process.communicate(input=script_content)
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
- result = cli_install.run_install_script(
204
- "https://example.com/install.sh",
205
- "Example",
206
- expected_sha256,
207
- env=env,
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.assert_called_once_with(
212
- ["bash"],
213
- stdin=cli_install.subprocess.PIPE,
214
- stderr=cli_install.subprocess.PIPE,
215
- env=env,
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