From df95efcb1f0ed28799276ec55acbc986031acb67 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 10 Aug 2026 13:38:00 +0300 Subject: [PATCH 1/3] feat: add nix flake with package, overlay, and home-manager module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flake.nix: buildNpmPackage derivation for web-model-bridge - tsup ESM build via npm run build - makeWrapper binary pointing nodejs_22 → dist/cli.js - node_modules bundled for runtime deps (playwright-core etc.) - dashboard static files copied in postBuild - overlays.default: injects pkgs.web-model-bridge into any nixpkgs instance without adding a second nixpkgs to the flake consumer - homeManagerModules.default: home-manager module with options enable / package / port / host / configFile / extraArgs; adds binary to home.packages and wires a systemd user service - flake.lock: pinned inputs (nixpkgs-unstable, home-manager, flake-utils) - README.md: add Nix / NixOS section with consumer usage guide, overlay-only usage, module options table, and nav link --- README.md | 83 +++++++++++++++++++++++++- flake.lock | 82 ++++++++++++++++++++++++++ flake.nix | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/README.md b/README.md index 4bbb760..19687b4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Use Claude, ChatGPT, DeepSeek, and 8 more AI models — completely free, zero AP [TypeScript](https://www.typescriptlang.org/) [Tests](#testing) -[Quick Start](#quick-start) · [Supported Models](#supported-models) · [Configuration](#configuration) · [API Reference](#api-reference) · [Contributing](#contributing) +[Quick Start](#quick-start) · [Supported Models](#supported-models) · [Configuration](#configuration) · [API Reference](#api-reference) · [Nix / NixOS](#nix--nixos) · [Contributing](#contributing) @@ -249,6 +249,87 @@ curl http://localhost:3456/v1/messages \ └──────────────────────────────┘ ``` +## Nix / NixOS + +The flake exposes a package, an overlay, and a home-manager module. + +### Try it without installing + +```bash +nix run github:linuxhsj/WebModel +``` + +### Add to your flake + +```nix +# flake.nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + web-model-bridge = { + url = "github:linuxhsj/WebModel"; + inputs.nixpkgs.follows = "nixpkgs"; # reuse your nixpkgs — no extra instance + }; + }; + + outputs = { nixpkgs, home-manager, web-model-bridge, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + # 1. Inject the overlay so pkgs.web-model-bridge is available everywhere + { nixpkgs.overlays = [ web-model-bridge.overlays.default ]; } + + home-manager.nixosModules.home-manager + { + home-manager.users.alice = { + imports = [ web-model-bridge.homeManagerModules.default ]; + + # 2. Enable the module — the package comes from pkgs via the overlay + programs.web-model-bridge = { + enable = true; + port = 3456; # default + host = "127.0.0.1"; # default + # configFile = ./webmodel.yml; # optional YAML config + # extraArgs = [ "--no-open" ]; # optional extra CLI flags + }; + }; + } + ]; + }; + }; +} +``` + +The home-manager module: +- Adds `web-model-bridge` to `home.packages` (binary in `$PATH`) +- Creates a **systemd user service** (`systemctl --user start web-model-bridge`) that auto-starts the server on login + +### Available module options + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `enable` | bool | `false` | Enable the module | +| `package` | package | `pkgs.web-model-bridge` | Override the package | +| `port` | port | `3456` | Listening port | +| `host` | string | `"127.0.0.1"` | Bind address | +| `configFile` | path \| null | `null` | Path to YAML config file | +| `extraArgs` | list of string | `[]` | Extra CLI arguments | + +### Just the overlay (without home-manager) + +```nix +{ nixpkgs.overlays = [ web-model-bridge.overlays.default ]; } + +# then anywhere in your config: +environment.systemPackages = [ pkgs.web-model-bridge ]; +``` + +--- + ## Troubleshooting diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a10831a --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786356609, + "narHash": "sha256-ou8fYz5w9yhXC8YbvvExybFIa19MyW5G/8dEPqa90hM=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "c30c7955cec30d664a9baced6bc0112e263d4647", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786106723, + "narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..65e4150 --- /dev/null +++ b/flake.nix @@ -0,0 +1,168 @@ +{ + description = "web-model-bridge — bridge web AI models through an OpenAI-compatible API"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, home-manager }: + let + # ── package definition ──────────────────────────────────────────────── + # Accepts the pkgs set so we can reuse it from any nixpkgs instance. + mkWebModelBridge = pkgs: pkgs.buildNpmPackage { + pname = "web-model-bridge"; + version = "0.1.0"; + + src = ./.; + + # Run `nix build` once with a fake hash, let nix tell you the real one, + # then replace the placeholder below. + npmDepsHash = "sha256-wHoZ9lUMtXQ8LhSCQCc30d9K7+ByfrF6/BS3B1PVvt0="; + + # tsup produces a single ESM bundle; makeWrapper creates the bin wrapper. + nativeBuildInputs = [ pkgs.nodejs_22 pkgs.makeWrapper ]; + + # The npm "build" script runs tsup. + # buildNpmPackage runs `npm run build` by default when a build script exists. + + # Copy the dashboard static files that tsup's onSuccess hook normally handles. + postBuild = '' + mkdir -p dist/dashboard + for f in src/dashboard/index.html src/dashboard/app.js src/dashboard/style.css; do + [ -f "$f" ] && cp "$f" dist/dashboard/ || true + done + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/web-model-bridge + cp -r dist $out/lib/web-model-bridge/ + + # Keep node_modules for runtime requires (playwright-core etc.) + cp -r node_modules $out/lib/web-model-bridge/ + + mkdir -p $out/bin + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/web-model-bridge \ + --add-flags "$out/lib/web-model-bridge/dist/cli.js" + + runHook postInstall + ''; + + meta = { + description = "Bridge web AI models through an OpenAI-compatible API"; + license = pkgs.lib.licenses.mit; + mainProgram = "web-model-bridge"; + }; + }; + + # ── overlay ─────────────────────────────────────────────────────────── + overlay = final: _prev: { + web-model-bridge = mkWebModelBridge final; + }; + + # ── home-manager module ─────────────────────────────────────────────── + homeManagerModule = { config, lib, pkgs, ... }: + let cfg = config.programs.web-model-bridge; + in { + options.programs.web-model-bridge = { + enable = lib.mkEnableOption "web-model-bridge OpenAI-compatible web AI proxy"; + + package = lib.mkOption { + type = lib.types.package; + # Resolved from pkgs, which already has the overlay applied by + # the consumer — no second nixpkgs instance needed. + default = pkgs.web-model-bridge; + defaultText = lib.literalExpression "pkgs.web-model-bridge"; + description = "The web-model-bridge package to use."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3000; + description = "Port the server listens on."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Host the server binds to."; + }; + + configFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to a YAML config file (passed via --config)."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Extra arguments to pass to the web-model-bridge CLI."; + }; + }; + + config = lib.mkIf cfg.enable { + # Make the binary available in the user's PATH. + home.packages = [ cfg.package ]; + + # Optionally wire up a systemd user service so the server starts + # automatically on login. + systemd.user.services.web-model-bridge = { + Unit = { + Description = "web-model-bridge OpenAI-compatible web AI proxy"; + After = [ "graphical-session.target" ]; + }; + Service = { + ExecStart = lib.concatStringsSep " " ( + [ "${cfg.package}/bin/web-model-bridge" "serve" + "--port" (toString cfg.port) + "--host" cfg.host + ] + ++ lib.optionals (cfg.configFile != null) [ "--config" (toString cfg.configFile) ] + ++ cfg.extraArgs + ); + Restart = "on-failure"; + RestartSec = "5s"; + }; + Install.WantedBy = [ "default.target" ]; + }; + }; + }; + + in + # ── per-system outputs (packages, devShells, …) ─────────────────────── + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; overlays = [ overlay ]; }; + in + { + packages = { + web-model-bridge = pkgs.web-model-bridge; + default = pkgs.web-model-bridge; + }; + + devShells.default = pkgs.mkShell { + buildInputs = [ pkgs.nodejs_22 ]; + shellHook = '' + echo "web-model-bridge dev shell" + echo "Run: npm install && npm run build" + ''; + }; + } + ) + + # ── system-agnostic outputs ─────────────────────────────────────────── + // { + # Conventional overlay output (consumers do: nixpkgs.overlays = [ inputs.web-model-bridge.overlays.default ]) + overlays.default = overlay; + + # home-manager modules (consumers do: imports = [ inputs.web-model-bridge.homeManagerModules.default ]) + homeManagerModules.default = homeManagerModule; + }; +} From 17eb9eb1770a4e649551279fb2fca90e26bec757 Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 10 Aug 2026 15:26:48 +0300 Subject: [PATCH 2/3] Add google-chrome to home-manager module --- .gitignore | 1 + flake.nix | 180 +++++++++++++++++++++++++++++------------------------ 2 files changed, 98 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index 0447c90..12e8c69 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ coverage/ *.log .DS_Store +.result diff --git a/flake.nix b/flake.nix index 65e4150..4cac542 100644 --- a/flake.nix +++ b/flake.nix @@ -10,11 +10,16 @@ }; }; - outputs = { self, nixpkgs, flake-utils, home-manager }: - let - # ── package definition ──────────────────────────────────────────────── - # Accepts the pkgs set so we can reuse it from any nixpkgs instance. - mkWebModelBridge = pkgs: pkgs.buildNpmPackage { + outputs = { + self, + nixpkgs, + flake-utils, + home-manager, + }: let + # ── package definition ──────────────────────────────────────────────── + # Accepts the pkgs set so we can reuse it from any nixpkgs instance. + mkWebModelBridge = pkgs: + pkgs.buildNpmPackage { pname = "web-model-bridge"; version = "0.1.0"; @@ -25,7 +30,7 @@ npmDepsHash = "sha256-wHoZ9lUMtXQ8LhSCQCc30d9K7+ByfrF6/BS3B1PVvt0="; # tsup produces a single ESM bundle; makeWrapper creates the bin wrapper. - nativeBuildInputs = [ pkgs.nodejs_22 pkgs.makeWrapper ]; + nativeBuildInputs = [pkgs.nodejs_22 pkgs.makeWrapper]; # The npm "build" script runs tsup. # buildNpmPackage runs `npm run build` by default when a build script exists. @@ -61,94 +66,104 @@ }; }; - # ── overlay ─────────────────────────────────────────────────────────── - overlay = final: _prev: { - web-model-bridge = mkWebModelBridge final; + # ── overlay ─────────────────────────────────────────────────────────── + overlay = final: _prev: { + web-model-bridge = mkWebModelBridge final; + }; + + # ── home-manager module ─────────────────────────────────────────────── + homeManagerModule = { + config, + lib, + pkgs, + ... + }: let + cfg = config.programs.web-model-bridge; + in { + options.programs.web-model-bridge = { + enable = lib.mkEnableOption "web-model-bridge OpenAI-compatible web AI proxy"; + + package = lib.mkOption { + type = lib.types.package; + # Resolved from pkgs, which already has the overlay applied by + # the consumer — no second nixpkgs instance needed. + default = pkgs.web-model-bridge; + defaultText = lib.literalExpression "pkgs.web-model-bridge"; + description = "The web-model-bridge package to use."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 3000; + description = "Port the server listens on."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Host the server binds to."; + }; + + configFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to a YAML config file (passed via --config)."; + }; + + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Extra arguments to pass to the web-model-bridge CLI."; + }; }; - # ── home-manager module ─────────────────────────────────────────────── - homeManagerModule = { config, lib, pkgs, ... }: - let cfg = config.programs.web-model-bridge; - in { - options.programs.web-model-bridge = { - enable = lib.mkEnableOption "web-model-bridge OpenAI-compatible web AI proxy"; - - package = lib.mkOption { - type = lib.types.package; - # Resolved from pkgs, which already has the overlay applied by - # the consumer — no second nixpkgs instance needed. - default = pkgs.web-model-bridge; - defaultText = lib.literalExpression "pkgs.web-model-bridge"; - description = "The web-model-bridge package to use."; - }; - - port = lib.mkOption { - type = lib.types.port; - default = 3000; - description = "Port the server listens on."; - }; - - host = lib.mkOption { - type = lib.types.str; - default = "127.0.0.1"; - description = "Host the server binds to."; - }; - - configFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to a YAML config file (passed via --config)."; - }; - - extraArgs = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = []; - description = "Extra arguments to pass to the web-model-bridge CLI."; - }; - }; + config = lib.mkIf cfg.enable { + # Make the binary available in the user's PATH. + home.packages = [cfg.package pkgs.google-chrome]; - config = lib.mkIf cfg.enable { - # Make the binary available in the user's PATH. - home.packages = [ cfg.package ]; - - # Optionally wire up a systemd user service so the server starts - # automatically on login. - systemd.user.services.web-model-bridge = { - Unit = { - Description = "web-model-bridge OpenAI-compatible web AI proxy"; - After = [ "graphical-session.target" ]; - }; - Service = { - ExecStart = lib.concatStringsSep " " ( - [ "${cfg.package}/bin/web-model-bridge" "serve" - "--port" (toString cfg.port) - "--host" cfg.host - ] - ++ lib.optionals (cfg.configFile != null) [ "--config" (toString cfg.configFile) ] - ++ cfg.extraArgs - ); - Restart = "on-failure"; - RestartSec = "5s"; - }; - Install.WantedBy = [ "default.target" ]; - }; + # Optionally wire up a systemd user service so the server starts + # automatically on login. + systemd.user.services.web-model-bridge = { + Unit = { + Description = "web-model-bridge OpenAI-compatible web AI proxy"; + After = ["graphical-session.target"]; }; + Service = { + ExecStart = lib.concatStringsSep " " ( + [ + "${cfg.package}/bin/web-model-bridge" + "--port" + (toString cfg.port) + "--host" + cfg.host + "--no-open" + ] + ++ lib.optionals (cfg.configFile != null) ["--config" (toString cfg.configFile)] + ++ cfg.extraArgs + ); + Restart = "on-failure"; + RestartSec = "5s"; + }; + Install.WantedBy = ["default.target"]; }; - - in + }; + }; + in # ── per-system outputs (packages, devShells, …) ─────────────────────── - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { inherit system; overlays = [ overlay ]; }; - in - { + flake-utils.lib.eachDefaultSystem ( + system: let + pkgs = import nixpkgs { + inherit system; + overlays = [overlay]; + }; + in { packages = { web-model-bridge = pkgs.web-model-bridge; default = pkgs.web-model-bridge; }; devShells.default = pkgs.mkShell { - buildInputs = [ pkgs.nodejs_22 ]; + buildInputs = [pkgs.nodejs_22]; shellHook = '' echo "web-model-bridge dev shell" echo "Run: npm install && npm run build" @@ -156,7 +171,6 @@ }; } ) - # ── system-agnostic outputs ─────────────────────────────────────────── // { # Conventional overlay output (consumers do: nixpkgs.overlays = [ inputs.web-model-bridge.overlays.default ]) From b4a24ad7562785ad97f205bedf2db5f5c629f99a Mon Sep 17 00:00:00 2001 From: Youssef Date: Mon, 10 Aug 2026 18:36:18 +0300 Subject: [PATCH 3/3] fix(cli,doctor): support multiple browsers and robust launch/CDP detection on NixOS --- src/cli.ts | 28 +++++++++++++------------ src/doctor.ts | 57 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 079ae8f..aec273c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,7 +22,7 @@ import type { BaseProvider } from './core/provider.js'; import { homedir, platform } from 'node:os'; import { join } from 'node:path'; import { mkdirSync } from 'node:fs'; -import { execSync } from 'node:child_process'; +import { execSync, spawn } from 'node:child_process'; import { createServer } from 'node:net'; import { runDoctor, printDoctorResults, findChromePath } from './doctor.js'; @@ -62,18 +62,18 @@ async function findAvailablePort(preferred: number, host: string): Promise/dev/null &`, { shell: '/bin/zsh' }); - } else if (os === 'win32') { - execSync(`start "" "${chromePath}" ${args}`, { shell: 'cmd.exe' }); - } else { - execSync(`"${chromePath}" ${args} &>/dev/null &`, { shell: '/bin/bash' }); - } + const child = spawn(chromePath, args, { + detached: true, + stdio: 'ignore' + }); + child.unref(); // Wait for CDP to become available (up to 10 seconds) for (let i = 0; i < 20; i++) { diff --git a/src/doctor.ts b/src/doctor.ts index d4f4f00..1e8acd3 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -45,17 +45,17 @@ export async function runDoctor(): Promise { } catch { // Version detection failed, but Chrome exists } - results.push({ name: 'Chrome', status: 'ok', message: `Found at ${chromePath}${versionInfo}` }); + results.push({ name: 'Browser', status: 'ok', message: `Found at ${chromePath}${versionInfo}` }); } else { results.push({ - name: 'Chrome', + name: 'Browser', status: 'fail', - message: 'Google Chrome not found', + message: 'No Chromium-based browser found (Chrome, Thorium, Brave, Chromium, Edge, Vivaldi)', fix: os === 'win32' - ? 'Install from https://www.google.com/chrome/' + ? 'Install from https://www.google.com/chrome/ or https://thorium.rocks/' : os === 'darwin' - ? 'Install: brew install --cask google-chrome' - : 'Install: sudo apt install google-chrome-stable OR sudo dnf install google-chrome-stable', + ? 'Install: brew install --cask google-chrome OR brew install --cask brave-browser' + : 'Install a Chromium-based browser, e.g.: google-chrome, thorium-browser, chromium, brave', }); } @@ -121,7 +121,13 @@ export function findChromePath(): string | undefined { const paths = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Thorium.app/Contents/MacOS/Thorium', + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/Applications/Vivaldi.app/Contents/MacOS/Vivaldi', `${process.env.HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, + `${process.env.HOME}/Applications/Thorium.app/Contents/MacOS/Thorium`, + `${process.env.HOME}/Applications/Brave Browser.app/Contents/MacOS/Brave Browser`, ]; for (const p of paths) { if (existsSync(p)) return p; @@ -129,23 +135,40 @@ export function findChromePath(): string | undefined { } if (os === 'linux') { + const user = process.env.USER || 'youssef'; + const home = process.env.HOME || `/home/${user}`; + + const candidates = [ + 'google-chrome', 'google-chrome-stable', + 'thorium-browser', 'thorium', + 'chromium', 'chromium-browser', + 'brave-browser', 'brave', + 'microsoft-edge', 'microsoft-edge-stable', + 'vivaldi', 'vivaldi-stable', + 'ungoogled-chromium', + ]; + const paths = [ - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', + // Standard distro bins + ...candidates.map(c => `/usr/bin/${c}`), '/snap/bin/chromium', + // Nix/NixOS user & system profiles + ...candidates.map(c => `${home}/.nix-profile/bin/${c}`), + ...candidates.map(c => `/etc/profiles/per-user/${user}/bin/${c}`), + ...candidates.map(c => `/run/current-system/sw/bin/${c}`), ]; + for (const p of paths) { if (existsSync(p)) return p; } - // Try which - try { - return execSync('which google-chrome || which chromium || which chromium-browser', { - encoding: 'utf-8', - }).trim(); - } catch { - // Not found + // Try which — covers NixOS and other distros with non-standard PATH-based installs + for (const bin of candidates) { + try { + const p = execSync(`which ${bin}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + if (p) return p; + } catch { + // not found, try next + } } }