Skip to content

Commit 90f398d

Browse files
committed
Address PR review: per-version fallback cache, node prereq, accurate npx wording
- M2: cache the npm-install fallback's resolved script path per version for the process lifetime (mirrors the Node CLI's installedCoanaScriptPathsByVersion), so a repeated fallback installs once instead of re-installing + leaking a temp dir each call. - M3: surface a clear error when `node` is missing in the fallback (instead of an opaque FileNotFoundError after a costly npm install), and add `node` to the up-front prereq check. - M1: correct the overstated 'npx --force disables the cache' wording in docstrings, docs, and CHANGELOG. The code already matches the Node CLI exactly (npx --yes --force); --force does not force a re-download of an already-cached pinned version, so the docs now describe what the flags actually do rather than claiming a cache bypass. Adds tests for per-version caching, node-missing, and real _resolve_coana_bin / _build_coana_node_cmd parsing.
1 parent 1ddb57f commit 90f398d

5 files changed

Lines changed: 141 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@
1212
untouched (never auto-updated or downgraded).
1313
- Opt into always-newest with `--reach-version latest`; pin an explicit version with
1414
`--reach-version <semver>` (unchanged).
15-
- npx caching is now disabled (`npx --yes --force`), matching the Socket Node CLI, so a
16-
corrupt/partial npx cache entry can't wedge a run.
15+
- Runs the engine via `npx --yes --force` (the same flags the Socket Node CLI passes for
16+
coana); `--yes` skips npx's interactive install prompt so non-interactive/CI runs don't hang.
1717
- Added an `npm install` + `node` fallback for when the `npx` launcher is missing or fails
18-
before the engine starts. Tunable via `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` (use the
19-
fallback as the primary path) and `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` (never fall back).
20-
Also strips `npm_package_*` env vars before spawning the engine to avoid `E2BIG` in large
21-
monorepos.
18+
before the engine starts. The installed engine is cached per version for the process
19+
lifetime (installs once). Tunable via `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` (use the fallback
20+
as the primary path) and `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` (never fall back). `node` is
21+
now part of the up-front prerequisite check. Also strips `npm_package_*` env vars before
22+
spawning the engine to avoid `E2BIG` in large monorepos.
2223

2324
## 2.4.6
2425

docs/cli-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ If you don't want to provide the Socket API Token every time then you can use th
264264
The Python CLI verifies the following **up front** (before invoking the analysis engine) and exits with code **3** if any are unmet:
265265
- `npm` - Required (verified up front; ships alongside `npx`)
266266
- `npx` - Required to fetch (on first use) and run `@coana-tech/cli` (the analysis engine)
267+
- `node` - Required to run the engine (used directly by the `npm install` fallback)
267268
- `uv` - Required by the analysis engine
268269
- An **Enterprise** Socket organization plan (any `enterprise*` plan, including Enterprise trials)
269270
@@ -313,7 +314,7 @@ Sample config files:
313314
314315
For CI-specific examples and guidance, see [`ci-cd.md`](ci-cd.md).
315316
316-
The CLI runs a pinned `@coana-tech/cli` version via `npx --yes --force`; it does **not** auto-update the engine or install it globally. `--force` disables the npx cache (matching the Socket Node CLI) so a corrupt or partial cache entry can't wedge a run. 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.
317+
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.
317318
318319
The launcher fallback can be tuned via environment variables:
319320
- `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken).

socketsecurity/core/tools/reachability.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
# Pass --reach-version latest to opt into the newest published version instead.
1919
DEFAULT_COANA_CLI_VERSION = "15.3.24"
2020

21+
# Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version.
22+
# Lives for the process lifetime so repeated fallback invocations install only once
23+
# (mirrors the Node CLI's installedCoanaScriptPathsByVersion).
24+
_INSTALLED_COANA_SCRIPT_PATHS: Dict[str, str] = {}
25+
2126

2227
def _build_caller_user_agent() -> str:
2328
"""Build the SOCKET_CALLER_USER_AGENT string forwarded to the coana CLI.
@@ -297,10 +302,12 @@ def _spawn_coana(
297302
) -> int:
298303
"""Run coana for the given args, returning the process exit code.
299304
300-
Primary path: ``npx --yes --force @coana-tech/cli@<version> ...``. ``--force``
301-
bypasses the npx cache so a corrupt/partial cache entry can't wedge the run, and
302-
``--yes`` auto-confirms the install prompt (parity with the Node CLI's dlx path,
303-
which passes ``--force`` for npm/npx and ``npm_config_dlx_cache_max_age=0`` for pnpm).
305+
Primary path: ``npx --yes --force @coana-tech/cli@<version> ...`` — the exact flags
306+
the Socket Node CLI passes for coana. ``--yes`` skips npx's interactive install
307+
confirmation so non-interactive/CI runs don't hang. ``--force`` matches the Node CLI
308+
(it opts out of npm's prompts/protections and refreshes within-range specs); note it
309+
does NOT force a re-download of an already-cached pinned version — npx still reuses a
310+
cached pinned package, so this is parity with the Node CLI, not a cache bypass.
304311
305312
Fallback path: if npx is missing, or its launcher dies before coana starts, install
306313
@coana-tech/cli into a temp dir via ``npm install`` and run it directly via ``node``.
@@ -315,7 +322,7 @@ def _spawn_coana(
315322
return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd)
316323

317324
package_spec = f"@coana-tech/cli@{effective_version}"
318-
# --yes auto-confirms the install prompt; --force bypasses the npx cache.
325+
# --yes skips npx's install confirmation; --force matches the Node CLI's coana flags.
319326
npx_cmd = ["npx", "--yes", "--force", package_spec, *coana_args]
320327
log.debug(f"Reachability command: {' '.join(npx_cmd)}")
321328
try:
@@ -356,8 +363,32 @@ def _spawn_coana_via_npm_install(
356363
357364
Used when npx is unavailable or its launcher fails before coana boots. Mirrors the
358365
Node CLI's npm-install fallback. Returns coana's exit code; raises if the install
359-
itself fails.
366+
itself fails or if ``node`` is unavailable.
360367
"""
368+
script_path = self._install_coana_to_tmpdir(version, env)
369+
node_cmd = self._build_coana_node_cmd(script_path, coana_args)
370+
log.debug(f"Reachability fallback command: {' '.join(node_cmd)}")
371+
try:
372+
result = subprocess.run(node_cmd, env=env, cwd=cwd, stdout=sys.stderr, stderr=sys.stderr)
373+
except FileNotFoundError as e:
374+
# The fallback exists for broken-launcher environments, but it still needs node.
375+
raise Exception(
376+
"`node` was not found on PATH; it is required to run the reachability engine "
377+
"via the npm-install fallback."
378+
) from e
379+
return result.returncode
380+
381+
def _install_coana_to_tmpdir(self, version: str, env: Dict[str, str]) -> str:
382+
"""``npm install`` @coana-tech/cli@<version> into a temp dir; return its executable JS path.
383+
384+
Caches the resolved path per version for the process lifetime so repeated fallback
385+
invocations install only once (mirrors the Node CLI's installCoanaToTmpdir). Raises if
386+
the install fails.
387+
"""
388+
cached = _INSTALLED_COANA_SCRIPT_PATHS.get(version)
389+
if cached and os.path.exists(cached):
390+
return cached
391+
361392
install_dir = tempfile.mkdtemp(prefix="socket-coana-")
362393
npm_cmd = [
363394
"npm", "install",
@@ -374,10 +405,8 @@ def _spawn_coana_via_npm_install(
374405
)
375406

376407
script_path = self._resolve_coana_bin(install_dir)
377-
node_cmd = self._build_coana_node_cmd(script_path, coana_args)
378-
log.debug(f"Reachability fallback command: {' '.join(node_cmd)}")
379-
result = subprocess.run(node_cmd, env=env, cwd=cwd, stdout=sys.stderr, stderr=sys.stderr)
380-
return result.returncode
408+
_INSTALLED_COANA_SCRIPT_PATHS[version] = script_path
409+
return script_path
381410

382411
@staticmethod
383412
def _resolve_coana_bin(install_dir: str) -> str:

socketsecurity/socketcli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def main_code():
189189
# Check for required dependencies if reachability analysis is enabled
190190
if config.reach:
191191
log.info("Reachability analysis enabled, checking for required dependencies...")
192-
required_deps = ["npm", "uv", "npx"]
192+
required_deps = ["npm", "node", "uv", "npx"]
193193
missing_deps = []
194194
found_deps = []
195195

tests/unit/test_reachability.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ def analyzer():
2424
return ReachabilityAnalyzer(MagicMock(), "test-api-token")
2525

2626

27+
@pytest.fixture(autouse=True)
28+
def _clear_coana_install_cache():
29+
"""The npm-install fallback caches resolved script paths in a module-level dict; isolate tests."""
30+
reachability._INSTALLED_COANA_SCRIPT_PATHS.clear()
31+
yield
32+
reachability._INSTALLED_COANA_SCRIPT_PATHS.clear()
33+
34+
2735
def _spawn_mock(analyzer, mocker, returncode=0, **kwargs):
2836
"""Run run_reachability_analysis with subprocess.run mocked to a fixed exit code.
2937
@@ -145,8 +153,8 @@ def _spec_in(cmd):
145153
return next(a for a in cmd if a.startswith("@coana-tech/cli@"))
146154

147155

148-
def test_npx_disables_cache_with_yes_and_force(analyzer, mocker):
149-
"""npx caching is disabled via --yes --force (parity with the Node CLI dlx path)."""
156+
def test_npx_uses_yes_and_force_flags(analyzer, mocker):
157+
"""npx is invoked with --yes --force — the exact flags the Node CLI passes for coana."""
150158
cmd, _ = _run(analyzer, mocker)
151159
assert cmd[0] == "npx"
152160
assert "--yes" in cmd
@@ -292,3 +300,85 @@ def fake_run(argv, **_kw):
292300
with pytest.raises(Exception):
293301
analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".")
294302
assert all(c[:2] != ["npm", "install"] for c in calls)
303+
304+
305+
def test_fallback_installs_once_per_version(analyzer, mocker):
306+
"""A second in-process fallback for the same version reuses the install (no re-install)."""
307+
mocker.patch.object(analyzer, "_extract_scan_id", return_value="scan-123")
308+
mocker.patch.object(reachability.tempfile, "mkdtemp", return_value="/tmp/socket-coana-cache")
309+
mocker.patch.object(
310+
analyzer,
311+
"_resolve_coana_bin",
312+
return_value="/tmp/socket-coana-cache/node_modules/@coana-tech/cli/coana.js",
313+
)
314+
# The cached script path must "exist" for the 2nd run to reuse it.
315+
mocker.patch.object(reachability.os.path, "exists", return_value=True)
316+
calls = []
317+
318+
def fake_run(argv, **_kw):
319+
calls.append(argv)
320+
m = MagicMock()
321+
m.returncode = 137 if argv[0] == "npx" else 0
322+
return m
323+
324+
mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run)
325+
analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".")
326+
analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".")
327+
328+
npm_installs = [c for c in calls if c[:2] == ["npm", "install"]]
329+
assert len(npm_installs) == 1 # installed once, reused on the second fallback
330+
331+
332+
def test_fallback_node_missing_raises_clear_error(analyzer, mocker):
333+
"""If `node` is missing in the fallback, surface a clear error (not opaque FileNotFoundError)."""
334+
mocker.patch.object(analyzer, "_extract_scan_id", return_value=None)
335+
mocker.patch.object(reachability.tempfile, "mkdtemp", return_value="/tmp/socket-coana-n")
336+
mocker.patch.object(
337+
analyzer,
338+
"_resolve_coana_bin",
339+
return_value="/tmp/socket-coana-n/node_modules/@coana-tech/cli/coana.js",
340+
)
341+
342+
def fake_run(argv, **_kw):
343+
if argv[0] == "npx":
344+
m = MagicMock()
345+
m.returncode = 137
346+
return m
347+
if argv[0] == "node":
348+
raise FileNotFoundError("node")
349+
m = MagicMock() # npm install succeeds
350+
m.returncode = 0
351+
return m
352+
353+
mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run)
354+
with pytest.raises(Exception, match="node"):
355+
analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".")
356+
357+
358+
def test_build_coana_node_cmd_js_vs_binary():
359+
f = ReachabilityAnalyzer._build_coana_node_cmd
360+
assert f("/x/coana.js", ["run", "."]) == ["node", "/x/coana.js", "run", "."]
361+
assert f("/x/coana.mjs", ["run"]) == ["node", "/x/coana.mjs", "run"]
362+
assert f("/x/coana", ["run", "."]) == ["/x/coana", "run", "."]
363+
364+
365+
def test_resolve_coana_bin_parses_package_json(analyzer, tmp_path):
366+
pkg_dir = tmp_path / "node_modules" / "@coana-tech" / "cli"
367+
pkg_dir.mkdir(parents=True)
368+
369+
# string bin
370+
(pkg_dir / "package.json").write_text('{"bin": "dist/coana.js"}')
371+
assert analyzer._resolve_coana_bin(str(tmp_path)) == str(pkg_dir / "dist" / "coana.js")
372+
373+
# dict bin, prefer the "coana" entry
374+
(pkg_dir / "package.json").write_text('{"bin": {"coana": "dist/c.js", "other": "x.js"}}')
375+
assert analyzer._resolve_coana_bin(str(tmp_path)) == str(pkg_dir / "dist" / "c.js")
376+
377+
# dict bin without "coana" -> first value
378+
(pkg_dir / "package.json").write_text('{"bin": {"other": "x.js"}}')
379+
assert analyzer._resolve_coana_bin(str(tmp_path)) == str(pkg_dir / "x.js")
380+
381+
# missing bin -> raises
382+
(pkg_dir / "package.json").write_text("{}")
383+
with pytest.raises(Exception, match="bin"):
384+
analyzer._resolve_coana_bin(str(tmp_path))

0 commit comments

Comments
 (0)