diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d3d05..8fcf88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.4.9 + +### Changed: consolidated coana launcher env vars into `SOCKET_CLI_COANA_LAUNCHER` + +- The reachability launcher is now tuned via a single `SOCKET_CLI_COANA_LAUNCHER` + environment variable (mirroring the Socket Node CLI): `auto` (default when unset; try + `npx` first, fall back to `npm install` + `node` on launcher-level failures), + `npm-install` (skip `npx` entirely), or `npx` (never fall back). An unrecognized value + logs a warning and behaves as `auto`. +- The legacy `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` and `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` + variables remain supported for back-compat when `SOCKET_CLI_COANA_LAUNCHER` is unset, but + are deprecated and no longer documented. + ## 2.4.8 ### Fixed: retry transient full-scan upload failures diff --git a/docs/cli-reference.md b/docs/cli-reference.md index adb41ca..dfee47e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -316,9 +316,10 @@ For CI-specific examples and guidance, see [`ci-cd.md`](ci-cd.md). The CLI runs a pinned `@coana-tech/cli` version via `npx --yes --force` (the same flags the Socket Node CLI passes for coana); it does **not** auto-update the engine or install it globally. `--yes` skips npx's interactive install prompt so non-interactive/CI runs don't hang. If the `npx` launcher is unavailable or fails before the engine starts, the CLI falls back to `npm install`-ing the pinned version into a temp directory and running it via `node`. Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan. -The launcher fallback can be tuned via environment variables: -- `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken). -- `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` — never fall back; surface the `npx` failure directly. +The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variable: +- `auto` (default when unset) — try `npx` first; fall back to `npm install` + `node` if the launcher fails before the engine starts. +- `npm-install` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken). +- `npx` — never fall back; surface the `npx` failure directly. #### Advanced Configuration | Parameter | Required | Default | Description | diff --git a/pyproject.toml b/pyproject.toml index 7876f8d..acff1fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.4.8" +version = "2.4.9" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9f1797f..cc95834 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.4.8' +__version__ = '2.4.9' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 88d3105..7cde14d 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -298,6 +298,31 @@ def _npx_launcher_failed_before_coana(returncode: int) -> bool: """ return returncode < 0 or returncode >= 128 + @staticmethod + def _resolve_coana_launcher_mode() -> str: + """Resolve the coana launcher mode: ``auto``, ``npx``, or ``npm-install``. + + ``SOCKET_CLI_COANA_LAUNCHER`` wins when set to a recognized value; an unrecognized + value warns and behaves as ``auto``. Only when it is unset/empty do the legacy vars + apply: ``SOCKET_CLI_COANA_FORCE_NPM_INSTALL`` -> ``npm-install``, else + ``SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK`` -> ``npx``. Mirrors the Node CLI. + """ + raw = os.environ.get("SOCKET_CLI_COANA_LAUNCHER", "") + mode = raw.strip().lower() + if mode in ("auto", "npx", "npm-install"): + return mode + if mode: + log.warning( + f'Ignoring unrecognized SOCKET_CLI_COANA_LAUNCHER value "{raw}"; ' + 'expected "auto", "npm-install", or "npx".' + ) + return "auto" + if os.environ.get("SOCKET_CLI_COANA_FORCE_NPM_INSTALL"): + return "npm-install" + if os.environ.get("SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK"): + return "npx" + return "auto" + def _spawn_coana( self, coana_args: List[str], @@ -318,14 +343,15 @@ def _spawn_coana( Fallback path: if npx is missing, or its launcher dies before coana starts, install @coana-tech/cli into a temp dir via ``npm install`` and run it directly via ``node``. - Toggle with ``SOCKET_CLI_COANA_FORCE_NPM_INSTALL`` (use the fallback as the primary - path) and ``SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK`` (never fall back). + Tune with ``SOCKET_CLI_COANA_LAUNCHER``: ``auto`` (default; npx with the npm-install + fallback), ``npm-install`` (skip npx, always use the fallback path), or ``npx`` + (never fall back). """ effective_version = self._resolve_coana_version(version) coana_env = self._sanitize_coana_env(env) - disable_fallback = bool(os.environ.get("SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK")) + launcher_mode = self._resolve_coana_launcher_mode() - if os.environ.get("SOCKET_CLI_COANA_FORCE_NPM_INSTALL"): + if launcher_mode == "npm-install": return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd) package_spec = f"@coana-tech/cli@{effective_version}" @@ -341,7 +367,7 @@ def _spawn_coana( ) except FileNotFoundError: # npx is not on PATH: the launcher provably never started coana. - if disable_fallback: + if launcher_mode == "npx": raise log.warning("npx not found on PATH; retrying reachability analysis via `npm install` + `node`.") return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd) @@ -349,7 +375,7 @@ def _spawn_coana( if result.returncode == 0: return 0 - if not disable_fallback and self._npx_launcher_failed_before_coana(result.returncode): + if launcher_mode != "npx" and self._npx_launcher_failed_before_coana(result.returncode): log.warning( f"npx launcher failed (exit {result.returncode}) before coana started; " "retrying reachability analysis via `npm install` + `node`." diff --git a/tests/unit/test_reachability.py b/tests/unit/test_reachability.py index 1130be3..86d7d0d 100644 --- a/tests/unit/test_reachability.py +++ b/tests/unit/test_reachability.py @@ -302,6 +302,54 @@ def fake_run(argv, **_kw): assert all(c[:2] != ["npm", "install"] for c in calls) +def test_launcher_npm_install_skips_npx(analyzer, mocker, monkeypatch): + """SOCKET_CLI_COANA_LAUNCHER=npm-install routes straight to npm install + node.""" + monkeypatch.setenv("SOCKET_CLI_COANA_LAUNCHER", "npm-install") + calls = _capture_spawns(analyzer, mocker, npx_behavior=0) + assert all(c[0] != "npx" for c in calls) + assert calls[0][:2] == ["npm", "install"] + assert calls[1][0] == "node" + + +def test_launcher_npx_propagates_npx_failure(analyzer, mocker, monkeypatch): + """SOCKET_CLI_COANA_LAUNCHER=npx: a launcher failure is NOT retried via npm.""" + monkeypatch.setenv("SOCKET_CLI_COANA_LAUNCHER", "npx") + mocker.patch.object(analyzer, "_extract_scan_id", return_value=None) + calls = [] + + def fake_run(argv, **_kw): + calls.append(argv) + m = MagicMock() + m.returncode = 137 + return m + + mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) + with pytest.raises(Exception): + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + assert calls[0][0] == "npx" + assert all(c[:2] != ["npm", "install"] for c in calls) + + +def test_launcher_overrides_legacy_vars(analyzer, mocker, monkeypatch): + """A recognized SOCKET_CLI_COANA_LAUNCHER wins; legacy vars are ignored entirely.""" + monkeypatch.setenv("SOCKET_CLI_COANA_LAUNCHER", "auto") + monkeypatch.setenv("SOCKET_CLI_COANA_FORCE_NPM_INSTALL", "1") + monkeypatch.setenv("SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK", "1") + calls = _capture_spawns(analyzer, mocker, npx_behavior=137) + assert calls[0][0] == "npx" # force-npm-install ignored: npx still attempted + assert calls[1][:2] == ["npm", "install"] # disable-fallback ignored: fallback runs + assert calls[2][0] == "node" + + +def test_launcher_unrecognized_value_behaves_as_auto(analyzer, mocker, monkeypatch): + """An unrecognized SOCKET_CLI_COANA_LAUNCHER value warns and behaves as auto.""" + monkeypatch.setenv("SOCKET_CLI_COANA_LAUNCHER", "bogus") + calls = _capture_spawns(analyzer, mocker, npx_behavior=137) + assert calls[0][0] == "npx" + assert calls[1][:2] == ["npm", "install"] + assert calls[2][0] == "node" + + def test_fallback_installs_once_per_version(analyzer, mocker): """A second in-process fallback for the same version reuses the install (no re-install).""" mocker.patch.object(analyzer, "_extract_scan_id", return_value="scan-123") diff --git a/uv.lock b/uv.lock index 0ed1361..86d19ff 100644 --- a/uv.lock +++ b/uv.lock @@ -1283,7 +1283,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.4.8" +version = "2.4.9" source = { editable = "." } dependencies = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'" },