Skip to content

Commit de304a3

Browse files
author
Stew Alexander
committed
Route neoss and eg to routes that exist, and test that eget tools resolve
The installer declared both as eget tools. eget cannot install either: neoss moved to TypeScript and publishes on npm, having never attached an asset to any of its ten releases, and eg has no GitHub releases at all and ships on PyPI. On a real machine these failed at install time with 'no candidates found' and a 404. neoss now installs via npm, eg via pip. npm is a new install method, so it apt-installs npm first rather than dying with 'npm: not found' on a clean Debian box. The mocked tests could never have caught this: they prove the installer calls eget correctly, not that eget finds anything at the far end. Added an opt-in network test that asks GitHub whether every eget tool still ships a Linux binary, ignoring manifests, checksums and distro packages eget will not unpack. Verified it fails on both tools when they are put back the broken way. It refuses to run at all when the GitHub API budget cannot cover the whole sweep, because a partial sweep that reports success is how this stayed hidden. 53 tests pass, up from 46.
1 parent 15c0f12 commit de304a3

3 files changed

Lines changed: 230 additions & 10 deletions

File tree

Lazy-Linux-Tool-Installer.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class InstallMethod(Enum):
3939
PIP = "pip"
4040
EGET = "eget"
4141
SNAP = "snap"
42+
NPM = "npm"
4243
BUILTIN = "builtin" # Already available (e.g., systemctl)
4344
MANUAL = "manual" # Requires manual installation
4445

@@ -300,6 +301,27 @@ def install_via_pip(package: str) -> bool:
300301
)
301302
return result.returncode == 0
302303

304+
@staticmethod
305+
def install_via_npm(package: str) -> bool:
306+
"""Install a global npm package, pulling in npm itself if it is absent.
307+
308+
Node is not part of a base Debian install, so the one tool that ships
309+
this way would otherwise fail on a clean machine with a bare
310+
"npm: not found".
311+
"""
312+
if not SystemChecker.has_command("npm"):
313+
print("npm not found, installing npm first...")
314+
if not Installer.install_via_apt("npm"):
315+
print("Could not install npm")
316+
return False
317+
print(f"Installing {package} via npm...")
318+
result = Installer.run_command(
319+
["sudo", "npm", "install", "-g", package],
320+
capture_output=True,
321+
timeout=NETWORK_CMD_TIMEOUT,
322+
)
323+
return result.returncode == 0
324+
303325
@staticmethod
304326
def install_via_snap(package: str, classic: bool = False) -> bool:
305327
"""Install package via snap."""
@@ -591,9 +613,10 @@ class ToolManager:
591613
"doggo": Tool("doggo", "doggo", InstallMethod.EGET, "doggo",
592614
"Modern dig alternative (dog successor)", "Network-Related Apps",
593615
github_repo="mr-karan/doggo"),
594-
"neoss": Tool("neoss", "neoss", InstallMethod.EGET, "neoss",
595-
"Modern ss alternative", "Network-Related Apps",
596-
github_repo="PabloLec/neoss"),
616+
# Published on npm, not as a GitHub release binary: the project is
617+
# TypeScript and has never attached an asset to any of its releases.
618+
"neoss": Tool("neoss", "neoss", InstallMethod.NPM, "neoss",
619+
"Modern ss alternative", "Network-Related Apps"),
597620

598621
# Misc CLI Terminal Apps
599622
"systemctl": Tool("systemctl", "systemctl", InstallMethod.BUILTIN, "systemd",
@@ -651,9 +674,9 @@ class ToolManager:
651674
"Parse command output to JSON", "Misc CLI Terminal Apps"),
652675
"visidata": Tool("visidata", "visidata", InstallMethod.PIP, "visidata",
653676
"CSV/data viewer", "Misc CLI Terminal Apps"),
654-
"eg": Tool("eg", "eg", InstallMethod.EGET, "eg",
655-
"TLDR-like command helper", "Misc CLI Terminal Apps",
656-
github_repo="srsudar/eg"),
677+
# Distributed on PyPI; the GitHub project cuts no releases at all.
678+
"eg": Tool("eg", "eg", InstallMethod.PIP, "eg",
679+
"TLDR-like command helper", "Misc CLI Terminal Apps"),
657680
"procs": Tool("procs", "procs", InstallMethod.EGET, "procs",
658681
"Modern ps replacement", "Misc CLI Terminal Apps",
659682
github_repo="dalance/procs"),
@@ -714,6 +737,8 @@ def install_tool(tool: Tool, dry_run: bool = False) -> bool:
714737
elif tool.method == InstallMethod.SNAP:
715738
classic_str = " (classic)" if tool.classic else ""
716739
print(f"[DRY RUN] Would install {tool.package} via snap{classic_str}")
740+
elif tool.method == InstallMethod.NPM:
741+
print(f"[DRY RUN] Would install {tool.package} via npm")
717742
elif tool.method == InstallMethod.EGET:
718743
print(f"[DRY RUN] Would install {tool.command} via eget from {tool.github_repo}")
719744
elif tool.method == InstallMethod.MANUAL:
@@ -733,6 +758,9 @@ def install_tool(tool: Tool, dry_run: bool = False) -> bool:
733758

734759
elif tool.method == InstallMethod.SNAP:
735760
ok = Installer.install_via_snap(tool.package, classic=tool.classic)
761+
762+
elif tool.method == InstallMethod.NPM:
763+
ok = Installer.install_via_npm(tool.package)
736764

737765
elif tool.method == InstallMethod.EGET:
738766
if tool.github_repo:
@@ -784,7 +812,7 @@ def get_user_consent(server_mode: bool = False, dry_run: bool = False) -> bool:
784812
if dry_run:
785813
print(" 👀 Shows what would be installed (DRY RUN - no changes)")
786814
else:
787-
print(" ✓ Installs missing tools automatically (apt, pip, eget, snap)")
815+
print(" ✓ Installs missing tools automatically (apt, pip, eget, snap, npm)")
788816
print(" ✓ Skips tools that are already installed")
789817
print(" ✓ Organizes everything by category")
790818
if server_mode:

README.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ sudo snap install --classic code
334334

335335
## Installer options and notes
336336

337-
`Lazy-Linux-Tool-Installer.py` routes each tool to apt, pip, snap, or eget,
337+
`Lazy-Linux-Tool-Installer.py` routes each tool to apt, pip, snap, eget, or npm,
338338
whichever suits it.
339339

340340
```bash
@@ -373,15 +373,33 @@ python3 -m unittest test_lazy_linux_tool_installer -v
373373
python3 -m unittest test_lazy_linux_tool_installer.TestSystemChecker -v
374374
```
375375

376+
### Checking the eget tools against upstream
377+
378+
The mocked tests prove the installer calls `eget` correctly. They cannot prove
379+
`eget` will find anything at the other end — a project can stop publishing
380+
release binaries at any time, and that failure only shows up on a real machine.
381+
One extra test asks GitHub whether every `eget` tool still ships a Linux binary.
382+
It needs the network, so it is off unless you ask for it:
383+
384+
```bash
385+
export GITHUB_TOKEN=$(gh auth token) # optional, but avoids the 60/hour limit
386+
LINUX_TOOLS_NETWORK_TESTS=1 python3 -m unittest test_lazy_linux_tool_installer
387+
```
388+
389+
If the API budget is too low to check every tool, the test skips rather than
390+
passing on a partial sweep.
391+
376392
### Test Results
377393

378394
**Current Status:** ✅ All tests passing
379395

380396
```
381-
Ran 46 tests in 0.008s
382-
OK
397+
Ran 53 tests in 0.011s
398+
OK (skipped=1)
383399
```
384400

401+
The skip is the network test above, which stays out of the offline run.
402+
385403
### Test Coverage
386404

387405
- **SystemChecker** — Debian-like detection, command availability, root check, curl/sudo requirements

test_lazy_linux_tool_installer.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
import os
1212
import subprocess
1313
import shutil
14+
import io
15+
import json
16+
import contextlib
17+
import urllib.request
18+
import urllib.error
1419

1520
# Import the module to test (handle hyphen in filename)
1621
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -215,6 +220,34 @@ def test_install_via_snap_success(self, mock_subprocess):
215220
call_kwargs = mock_subprocess.run.call_args[1]
216221
self.assertIn('timeout', call_kwargs)
217222

223+
@patch.object(dlt, 'subprocess')
224+
@patch.object(dlt, 'shutil')
225+
def test_install_via_npm_success(self, mock_shutil, mock_subprocess):
226+
"""npm already present: install the package globally."""
227+
mock_shutil.which.return_value = '/usr/bin/npm'
228+
mock_subprocess.run.return_value = subprocess.CompletedProcess(['sudo'], 0)
229+
self.assertTrue(dlt.Installer.install_via_npm('neoss'))
230+
self.assertIn('timeout', mock_subprocess.run.call_args[1])
231+
232+
@patch.object(dlt.Installer, 'install_via_apt')
233+
@patch.object(dlt, 'subprocess')
234+
@patch.object(dlt, 'shutil')
235+
def test_install_via_npm_bootstraps_npm(self, mock_shutil, mock_subprocess, mock_apt):
236+
"""npm missing: apt-install npm first rather than failing with 'not found'."""
237+
mock_shutil.which.return_value = None
238+
mock_apt.return_value = True
239+
mock_subprocess.run.return_value = subprocess.CompletedProcess(['sudo'], 0)
240+
self.assertTrue(dlt.Installer.install_via_npm('neoss'))
241+
mock_apt.assert_called_once_with('npm')
242+
243+
@patch.object(dlt.Installer, 'install_via_apt')
244+
@patch.object(dlt, 'shutil')
245+
def test_install_via_npm_gives_up_if_npm_unavailable(self, mock_shutil, mock_apt):
246+
"""If npm cannot be installed, report failure instead of pressing on."""
247+
mock_shutil.which.return_value = None
248+
mock_apt.return_value = False
249+
self.assertFalse(dlt.Installer.install_via_npm('neoss'))
250+
218251
@patch.object(dlt.Installer, 'install_binary_to_path')
219252
@patch.object(dlt, 'subprocess')
220253
@patch.object(dlt, 'os')
@@ -382,6 +415,44 @@ def test_install_tool_eget(self, mock_shutil, mock_install):
382415
self.assertTrue(result)
383416
mock_install.assert_called_once()
384417

418+
@patch.object(dlt.Installer, 'install_via_npm')
419+
@patch.object(dlt, 'shutil')
420+
def test_install_tool_npm(self, mock_shutil, mock_install):
421+
"""Test installing tool via npm."""
422+
mock_shutil.which.return_value = '/usr/local/bin/neoss'
423+
mock_install.return_value = True
424+
tool = dlt.ToolManager.TOOLS['neoss']
425+
self.assertTrue(dlt.ToolManager.install_tool(tool))
426+
mock_install.assert_called_once_with('neoss')
427+
428+
def test_every_method_is_handled_by_install_tool(self):
429+
"""Every InstallMethod a tool actually uses must have a dry-run branch.
430+
431+
Adding an enum member without wiring it up would otherwise leave the
432+
tool silently unhandled, which is how neoss and eg went unnoticed.
433+
"""
434+
used = {t.method for t in dlt.ToolManager.TOOLS.values()}
435+
for method in used:
436+
with self.subTest(method=method.value):
437+
tool = next(t for t in dlt.ToolManager.TOOLS.values() if t.method == method)
438+
buf = io.StringIO()
439+
with contextlib.redirect_stdout(buf):
440+
handled = dlt.ToolManager.install_tool(tool, dry_run=True)
441+
self.assertTrue(handled)
442+
self.assertTrue(buf.getvalue().strip(),
443+
f"{method.value} produced no dry-run output")
444+
445+
def test_eget_tools_declare_a_repo_and_others_do_not(self):
446+
"""A github_repo is required for eget and meaningless for anything else."""
447+
for name, tool in dlt.ToolManager.TOOLS.items():
448+
with self.subTest(tool=name):
449+
if tool.method == dlt.InstallMethod.EGET:
450+
self.assertTrue(tool.github_repo,
451+
f"{name} installs via eget but names no repo")
452+
else:
453+
self.assertIsNone(tool.github_repo,
454+
f"{name} does not use eget but names a repo")
455+
385456

386457
class TestToolDataClass(unittest.TestCase):
387458
"""Test Tool dataclass."""
@@ -529,6 +600,109 @@ def test_main_success_flow(self, mock_print, mock_install, mock_check_installed,
529600
self.assertEqual(mock_input.call_count, 1)
530601

531602

603+
@unittest.skipUnless(
604+
os.environ.get("LINUX_TOOLS_NETWORK_TESTS") == "1",
605+
"network test; set LINUX_TOOLS_NETWORK_TESTS=1 to run",
606+
)
607+
class TestEgetToolsResolveUpstream(unittest.TestCase):
608+
"""Ask GitHub whether every eget tool can still actually be fetched.
609+
610+
The mocked tests above prove the installer calls eget correctly. They
611+
cannot prove eget will find anything at the other end, and that is the
612+
failure that reached users: neoss stopped attaching release assets when it
613+
moved to npm, and eg never cut a GitHub release at all. Both were declared
614+
as eget tools for a long time, and both failed only at install time on a
615+
real machine.
616+
617+
This test is off by default because it needs the network and spends
618+
GitHub's unauthenticated rate limit. Set GITHUB_TOKEN to raise that limit.
619+
"""
620+
621+
# Files that mention Linux but are not something eget can install: update
622+
# manifests, checksums, signatures, and distro packages it will not unpack.
623+
NON_BINARY_SUFFIXES = (
624+
".yml", ".yaml", ".json", ".txt", ".md", ".sha256", ".sha256sum",
625+
".sig", ".asc", ".pem", ".sbom", ".deb", ".rpm",
626+
)
627+
628+
@classmethod
629+
def _usable_linux_asset(cls, asset_name: str) -> bool:
630+
lowered = asset_name.lower()
631+
return "linux" in lowered and not lowered.endswith(cls.NON_BINARY_SUFFIXES)
632+
633+
def _get(self, url: str) -> dict:
634+
headers = {
635+
"User-Agent": "Linux-Tools-test",
636+
"Accept": "application/vnd.github+json",
637+
}
638+
token = os.environ.get("GITHUB_TOKEN")
639+
if token:
640+
headers["Authorization"] = f"Bearer {token}"
641+
request = urllib.request.Request(url, headers=headers)
642+
with urllib.request.urlopen(request, timeout=30) as response:
643+
return json.load(response)
644+
645+
def _require_api_budget(self, needed: int) -> None:
646+
"""Skip before starting rather than half-checking the list.
647+
648+
Anonymous callers get sixty requests an hour, which one run can
649+
exhaust. Discovering that partway through would leave most tools
650+
unchecked while the run still reported success, so establish up front
651+
that the whole sweep can complete.
652+
"""
653+
try:
654+
rate = self._get("https://api.github.com/rate_limit")["rate"]
655+
except urllib.error.URLError as error:
656+
raise unittest.SkipTest(f"network unavailable: {error.reason}")
657+
if rate["remaining"] < needed:
658+
raise unittest.SkipTest(
659+
f"GitHub API budget too low: {rate['remaining']} left, {needed} "
660+
f"needed. Set GITHUB_TOKEN (export GITHUB_TOKEN=$(gh auth token))."
661+
)
662+
663+
def test_every_eget_tool_resolves_to_a_downloadable_asset(self):
664+
eget_tools = {
665+
name: tool.github_repo
666+
for name, tool in dlt.ToolManager.TOOLS.items()
667+
if tool.method == dlt.InstallMethod.EGET
668+
}
669+
self.assertTrue(eget_tools, "expected the installer to define eget tools")
670+
self._require_api_budget(len(eget_tools))
671+
672+
failures = []
673+
for name, repo in sorted(eget_tools.items()):
674+
try:
675+
release = self._get(
676+
f"https://api.github.com/repos/{repo}/releases/latest"
677+
)
678+
except urllib.error.HTTPError as error:
679+
if error.code in (403, 429):
680+
raise unittest.SkipTest(
681+
f"rate limited after checking {len(failures)} tools; "
682+
f"set GITHUB_TOKEN and rerun"
683+
)
684+
failures.append(
685+
f"{name}: no published release at {repo} (HTTP {error.code}), "
686+
f"so eget has nothing to download"
687+
)
688+
continue
689+
except urllib.error.URLError as error:
690+
raise unittest.SkipTest(f"network unavailable: {error.reason}")
691+
692+
assets = [asset["name"] for asset in release.get("assets", [])]
693+
if not [a for a in assets if self._usable_linux_asset(a)]:
694+
failures.append(
695+
f"{name}: {repo} release {release.get('tag_name')!r} publishes "
696+
f"no Linux binary eget can install (assets: {assets or 'none'})"
697+
)
698+
699+
self.assertEqual(
700+
[], failures,
701+
"eget tools that cannot actually be installed:\n "
702+
+ "\n ".join(failures),
703+
)
704+
705+
532706
if __name__ == '__main__':
533707
# Run tests with verbose output
534708
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)