shyim/composer-binary-downloader

Generic Composer plugin that downloads platform-specific binaries — FFI shared libraries, CLI tools, WASM modules — from GitHub releases or any URL, driven by composer.json extra configuration

Maintainers

Package info

github.com/shyim/composer-binary-downloader

Type:composer-plugin

pkg:composer/shyim/composer-binary-downloader

Transparency log

Statistics

Installs: 25

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-08-01 08:46 UTC

This package is auto-updated.

Last update: 2026-08-01 08:52:47 UTC


README

A generic Composer plugin that downloads platform-specific binaries. Any package declares what it needs in composer.json; the plugin fetches the right build for the host on composer install.

It replaces the pattern of writing a bespoke Composer plugin per native dependency — download from GitHub releases, verify the checksum, unpack, put the file where the loader expects it — with configuration.

Works for anything shipped as a per-platform release asset:

  • FFI shared libraries (.so / .dylib / .dll) — the original use case
  • CLI tools — a Rust or Go binary your package shells out to
  • WASM modules, ICU data, model files, or any other platform-specific blob

The engine does not care which: it fetches an asset, verifies it, and puts one file at a configured path.

Install

composer require shyim/composer-binary-downloader

Composer plugins must be allowed explicitly:

{
    "config": {
        "allow-plugins": { "shyim/composer-binary-downloader": true }
    }
}

Configure

Add extra.binaries to the package that needs the binary. Every installed package is scanned, as is the root project, so a wrapper package declares its native dependency once. Consumers approve the package a single time — via the interactive prompt or an extra.allow-binaries entry (see Security) — and need no other setup.

{
    "name": "shyim/sasso-ffi",
    "require": { "shyim/composer-binary-downloader": "^1.0" },
    "extra": {
        "binaries": {
            "sasso": {
                "version": "0.8.2",
                "repository": "shyim/sasso",
                "asset": "{name}-v{version}-{target}-c-api.{ext}",
                "archive": "tar.xz",
                "path": "bin/{target}/{library}",
                "targets": {
                    "x86_64-apple-darwin":        "libsasso.dylib",
                    "aarch64-apple-darwin":       "libsasso.dylib",
                    "x86_64-unknown-linux-gnu":   "libsasso.so",
                    "aarch64-unknown-linux-gnu":  "libsasso.so",
                    "x86_64-unknown-linux-musl":  "libsasso.so",
                    "aarch64-unknown-linux-musl": "libsasso.so",
                    "x86_64-pc-windows-msvc":     { "library": "sasso.dll", "archive": "zip" }
                },
                "checksums": {
                    "aarch64-apple-darwin": "5f511055100a96c935a734c783b880567fd65e54a87a51c583b7ca8223cee9e7"
                }
            }
        }
    }
}

The library lands at vendor/shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib.

Options

Key Required Default Meaning
version yes Release version. A leading v is stripped. "self" uses the declaring package's own version — see below.
repository yes* owner/repo for GitHub releases.
tag no v{version} Release tag template. Use {version} for projects that tag without the v.
url yes* Full URL template; use instead of repository for S3, GitLab, self-hosted.
asset no {name}-v{version}-{target}.{ext} Published asset file name.
archive no tar.gz tar.gz, tar.xz, tar.bz2, tar, zip, or none.
library yes** Shared-library file name inside the archive.
path no lib/{target}/{library} Install path, relative to the declaring package.
targets yes Map of target triple to per-target settings (or a library-name string).
checksums no Map of target triple to expected sha256.
env-prefix no upper-cased library name Prefix for the environment variables below.
optional no true When false, a download failure aborts composer install.
bin no Expose the tool as a vendor/bin command: true (named after the binary) or a command name.
download no eager lazy defers the download to first use instead of fetching at composer install.

* One of repository or url is required. ** Not required when archive is none; the asset's own name is used.

Per-target settings (asset, library, archive, checksum, url) override the library-wide value — real pipelines ship a .zip of foo.dll on Windows and a .tar.gz of libfoo.so elsewhere.

Placeholders

Usable in asset, url, library, and path:

Placeholder Expands to
{name} The library key (sasso)
{version} The configured version
{target} Target triple (aarch64-apple-darwin)
{arch} Architecture (aarch64)
{os} Rest of the triple (apple-darwin)
{ext} Archive extension (tar.xz); the library extension when archive is none
{libext} Shared-library extension for the target (dylib, so, dll)
{binext} Executable extension for the target (.exe on Windows, empty elsewhere)
{goos} / {goarch} Go-style names for the target (linux/darwin/windows, amd64/arm64) — matches goreleaser asset naming like tool_1.0_linux_amd64
{library} Resolved library file name (path only)

{libext} and {binext} follow the target rather than the host, so cross-target prefetching names files correctly. {binext} includes the dot, so "library": "mytool{binext}" yields mytool.exe on Windows and mytool elsewhere without a per-target override.

CLI tools on vendor/bin

A binary can be a command, not just a library. "bin" exposes it in Composer's bin-dir, and "download": "lazy" defers the fetch to first use — together they reproduce the pattern tools like frosh/shopmon-cli hand-write in a bin stub, as pure configuration:

{
    "name": "frosh/shopmon-cli",
    "require": { "shyim/composer-binary-downloader": "^1.0" },
    "extra": {
        "binaries": {
            "shopmon-cli": {
                "version": "self",
                "repository": "FriendsOfShopware/shopmon-cli",
                "tag": "{version}",
                "asset": "{name}_{version}_{goos}_{goarch}.{ext}",
                "archive": "tar.gz",
                "library": "shopmon-cli{binext}",
                "path": "bin/{version}/{target}/shopmon-cli{binext}",
                "bin": true,
                "download": "lazy",
                "targets": {
                    "x86_64-unknown-linux-gnu": {},
                    "aarch64-unknown-linux-gnu": {},
                    "x86_64-apple-darwin": {},
                    "aarch64-apple-darwin": {}
                }
            }
        }
    }
}

Any project requiring this package gets vendor/bin/shopmon-cli. Running it downloads the release on first use (with a notice on stderr), then executes it with stdin/stdout/stderr and the exit code passed straight through — via a true exec where pcntl is available, a waiting child process otherwise.

The pieces:

  • "version": "self" — the binary version is the Composer package's own version. Tagging the wrapper repo releases both; extra never needs a version bump. A branch install (dev-main) has no tagged release, so it fails at parse time with a message saying exactly that.
  • "bin": true — generate a proxy named after the binary (or pass a string for a different command name, e.g. "bin": "rg" for ripgrep). Generated proxies carry a marker; the plugin never overwrites or deletes a bin-dir file it did not generate, and removes its own when the declaration goes away.
  • "download": "lazy" — nothing fetched during composer install; the proxy (or Binaries::install()) fetches on demand. composer binary:install and --target prefetch it anyway — an image build asking explicitly should not be refused.
  • {version} in path — an upgrade fetches the new release instead of finding the old file at the same path and skipping it.
  • {goos}/{goarch} — goreleaser asset naming from one template.

Downloaded files are chmod 0755, so the tool is runnable as fetched. PHP code can call the same binary via Binaries::path('shopmon-cli').

For a package that must work even with plugins disabled, skip "bin" and ship a two-line stub as your own bin entry — Composer core links those without any plugin:

#!/usr/bin/env php
<?php
require __DIR__ . '/../../../autoload.php'; // adjust to taste

\Shyim\BinaryDownloader\Bin::run('shopmon-cli');

See examples/shopmon-cli.json and examples/ripgrep.json for complete files.

Supported target triples

Detection follows the PHP binary, not the CPU — an x86_64 PHP under Rosetta gets an x86_64 library. musl is detected via the dynamic linker, so Alpine resolves to -musl.

x86_64-apple-darwin        aarch64-apple-darwin
x86_64-unknown-linux-gnu   aarch64-unknown-linux-gnu
x86_64-unknown-linux-musl  aarch64-unknown-linux-musl
x86_64-pc-windows-msvc

Any triple works as a key — these are just what host detection produces.

Commands

composer binary:install                   # all libraries, host platform
composer binary:install sasso             # one library
composer binary:install -t all --force    # every target, re-download
composer binary:list                      # what is configured and what is on disk

binary:list is the diagnostic for a failing FFI::cdef(): it prints the exact path that was expected and whether it exists.

--target prefetches a platform other than the build host, which is what image builds need (an arm64 runner baking an x86_64 image). Libraries with no build for a requested target are reported and skipped rather than failing the run.

Without the plugin

Composer never loads the root package as a plugin, and --no-plugins disables it. The bundled script covers both, reading vendor/composer/installed.json directly:

php vendor/bin/binary-download
php vendor/bin/binary-download --list
php vendor/bin/binary-download sasso --target=all

Wire it into a checkout of the declaring package with a script:

{ "scripts": { "post-install-cmd": "@php vendor/bin/binary-download" } }

Runtime API

Binaries is the API for consuming packages — what you call to get the path of a downloaded binary, whether that means FFI::cdef() on a shared library or exec() on a CLI tool. It reads a data file the plugin generates at autoload-dump time (vendor/composer/installed-binaries.php), in the same spirit as Composer\InstalledVersions: no composer.json parsing per lookup.

use Shyim\BinaryDownloader\Binaries;

// a shared library
$ffi = FFI::cdef($header, Binaries::path('sasso'));

// or an executable
exec(escapeshellarg(Binaries::path('ripgrep')) . ' --version', $output);
Method Behaviour
path(string $name): string Resolved path. Never downloads. Throws if unusable.
install(string $name): string Same, but downloads when missing.
isInstalled(string $name): bool Never throws. For graceful degradation.
status(string $name): LibraryStatus Full diagnostic snapshot; never throws for a configured library.
names(): list<string> Every configured library name.

path() deliberately does no network I/O — a web request should not stall on a release host. Use install() where fetching is acceptable: a CLI tool, a warmup command, a test bootstrap.

Resolution order is <PREFIX>_LIBRARY, then the installed path, then (for install() only) a download.

Exceptions

All of them implement LibraryUnavailableExceptionInterface, so code that only cares "usable or not" needs one catch:

use Shyim\BinaryDownloader\Exception\LibraryUnavailableExceptionInterface;

try {
    $ffi = FFI::cdef($header, Binaries::path('sasso'));
} catch (LibraryUnavailableExceptionInterface $e) {
    // $e->getMessage() states the cause and the fix
    return new PurePhpFallback();
}
Exception Meaning
UnknownLibraryException No package declares that name. Lists what is configured.
PlatformNotSupportedException No build for this platform. Lists the configured targets.
LibraryNotInstalledException Configured, but not on disk.

LibraryNotInstalledException::reason() returns a Reason enum so callers can branch without matching message text:

Reason Cause
Missing Not downloaded yet — usually composer install --no-plugins.
Declined <PREFIX>_SKIP_DOWNLOAD or BINARY_DOWNLOADER_SKIP forbade it.
DownloadFailed A download was attempted and failed; getPrevious() has the cause.
ExplicitPathMissing <PREFIX>_LIBRARY names a file that is not there.
use Shyim\BinaryDownloader\Exception\LibraryNotInstalledException;
use Shyim\BinaryDownloader\Exception\Reason;

try {
    $path = Binaries::path('sasso');
} catch (LibraryNotInstalledException $e) {
    // Respect a deliberate opt-out; retry only what is worth retrying.
    if ($e->reason() === Reason::Declined) {
        throw $e;
    }

    $path = Binaries::install('sasso');
}

Each message names the specific cause and the command that fixes it, e.g.

sasso is not installed at /app/vendor/shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib,
and downloading is disabled by SASSO_SKIP_DOWNLOAD.
Unset it and run `composer binary:install sasso`, or point SASSO_LIBRARY at a library
you built yourself.

Diagnostics

status() never throws for a configured library, so a health check can report a broken install rather than handle exceptions:

$status = Binaries::status('sasso');

$status->installed;         // bool
$status->isSupported();     // false when no build exists for this platform
$status->isOverridden();    // true when <PREFIX>_LIBRARY is in effect
$status->target;            // 'aarch64-apple-darwin', or null
$status->path;              // where it is, or would be
$status->configuredTargets; // every triple the library publishes
echo $status->describe();   // one-line summary

The generated data file

The plugin writes vendor/composer/installed-binaries.php on every autoload dump. Paths are emitted relative to __DIR__, not baked in absolutely, so a vendor/ directory built in CI and copied into an image (COPY vendor/) still resolves — the same approach Composer uses for installed.php:

return [
    'sasso' => [
        'package' => 'shyim/sasso-ffi',
        'version' => '0.8.2',
        'env_prefix' => 'SASSO',
        'optional' => true,
        'targets' => [
            'aarch64-apple-darwin' => [
                'path' => __DIR__ . '/../shyim/sasso-ffi/bin/aarch64-apple-darwin/libsasso.dylib',
                'library' => 'libsasso.dylib',
                'url' => 'https://github.com/shyim/sasso/releases/download/v0.8.2/...',
            ],
        ],
    ],
];

Output is sorted and stable, so an unchanged installation produces no diff. Do not commit or edit it; re-dump the autoloader instead.

If the file is absent — the autoloader was dumped with --no-plugins, or you are in a checkout of the declaring package — Binaries falls back to reading composer.json directly. Same results, just slower.

Lower-level access

LibraryLocator exposes the parsed configuration (definition(), definitions()) for tooling that needs URLs, checksums, or every target rather than one resolved path.

Environment variables

Per binary, prefixed by env-prefix (default: the upper-cased binary name — so a binary named sasso gets SASSO_*):

Variable Effect
SASSO_LIBRARY Use this file instead; skips downloading entirely. Works for executables too.
SASSO_TARGET Fetch this target instead of the detected one.
SASSO_SKIP_DOWNLOAD Do not download. Fails at runtime if the file is missing.
SASSO_DOWNLOAD_BASE_URL Fetch assets from this base URL instead.
BINARY_DOWNLOADER_SKIP Global: skip every download this run.

Setting *_DOWNLOAD_BASE_URL drops pinned checksums, since a mirror may legitimately repackage an asset. See below.

Root overrides

A project can retarget a dependency's library — an internal mirror, a pinned version — without forking the package that declared it:

{
    "extra": {
        "binaries-overrides": {
            "sasso": { "url": "https://artifacts.internal/ffi/{name}-{version}-{target}.tar.xz" },
            "shyim/sasso-ffi:sasso": { "version": "0.9.0" }
        }
    }
}

Keys are a library name or vendor/package:library, the latter winning on conflict. An override naming no installed library is an error, not a silent no-op. Overrides are re-validated, so they cannot introduce a shape the parser would reject.

Checksums and overrides. A pinned digest describes one exact asset. An override that moves the download (url, version, repository, asset, archive) drops inherited checksums, because enforcing them against different bytes would turn a working mirror into a hard failure. Supply checksums in the override to keep verification:

{
    "extra": {
        "binaries-overrides": {
            "sasso": {
                "url": "https://artifacts.internal/ffi/{name}-{version}-{target}.tar.xz",
                "checksums": { "aarch64-apple-darwin": "5f5110..." }
            }
        }
    }
}

An override that only changes path keeps the checksums, since the bytes are unchanged.

Security: allow-binaries

Downloading — and, through bin proxies, executing — code from a URL chosen by a transitive dependency is the same decision Composer gates with allow-plugins. This plugin gates it the same way: nothing a dependency declares is fetched until the root project allows the package in extra.allow-binaries.

{
    "extra": {
        "allow-binaries": {
            "shyim/sasso-ffi": true,
            "frosh/*": true,
            "acme/sketchy-tool": false
        }
    }
}

Keys are package names or globs; the first matching entry wins, in declaration order. The root project's own extra.binaries are always trusted — that file is yours.

On an interactive composer install, each unreviewed package is prompted for once, Composer-style, and the answer is written to composer.json:

binary-downloader: acme/mytool-bin declares downloadable binaries:
  - mytool 2.5.0 from https://github.com/acme/mytool (exposed as vendor/bin/mytool)
Do you trust "acme/mytool-bin" to download and execute these binaries?
(writes "allow-binaries" to composer.json) [y,n,d,?]

y allows and persists, n denies and persists (no further prompts), d skips for this run only. Non-interactively (CI) nothing is downloaded and the warning carries the exact snippet to commit.

The gate holds on every path, not just the install hook:

  • the installer skips the package (fatally, if it set optional: false — it cannot both work and be refused);
  • no vendor/bin proxy is generated, and a previously generated one is removed when a package becomes denied;
  • the runtime refuses too: Binaries::path() / install() — and therefore a lazy first-use download — throw NotAllowedException, so composer.json is the only place permission can come from. No CLI flag or env variable bypasses it.

An explicitly false package is skipped silently; an unlisted one warns. Both show up in composer binary:list with the entry that would enable them.

Failure behaviour

Libraries are optional by default: a failure warns and composer install still succeeds. That keeps installs working on an unsupported platform or behind a blocked network, where the package may only be needed for autoloading, and the runtime can still fetch on first use. Set optional: false when the package cannot function without the binary.

Malformed configuration always fails loudly — it is the package author's bug, and ignoring it would hide a library that should have been installed.

binary:install exits non-zero on any failure, optional or not: an explicit request wants the binary, not a best effort.

Verification

Downloads are checked against checksums when present, and an archive is unpacked to a temporary directory and moved into place only once complete — an interrupted or mismatched download never leaves a partial library where the loader would find it.

Checksums are optional but recommended. Without one, a compromised release host or mirror is trusted implicitly.

License

MIT