Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.3.1 Under development
## 0.4.0 Under development

- refactor: deduplicate internals, centralize exception messages in `Foxy\Exception\Message`, and normalize their wording.
- feat: add the `native` asset manager that installs frontend dependencies from the npm registry in PHP, without a JavaScript manager.
- feat!: audit the `native` manager, make every manager auditable, select the manager on first use, and speed up CVE lookups and installs.

## 0.3.0 October 7, 2026

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,15 @@ Authorize the Composer plugin and install Foxy 0.3:

```bash
composer config allow-plugins.php-forge/foxy true
composer require php-forge/foxy:^0.3
composer require php-forge/foxy:^0.4
```

Selecting a manager explicitly is recommended for reproducible local and CI behavior:

```json
{
"require": {
"php-forge/foxy": "^0.3"
"php-forge/foxy": "^0.4"
},
"config": {
"allow-plugins": {
Expand Down Expand Up @@ -112,10 +112,11 @@ composer foxy:audit
composer foxy:audit --format=summary --no-dev --audit-level=high
```

Foxy normalizes the current npm, pnpm, Yarn, Bun, and Deno audit reports and identifies the affected package,
Foxy normalizes the current npm, pnpm, Yarn, Bun, Deno, and native audit reports and identifies the affected package,
advisory, severity, vulnerable range, and CVE identifiers when GitHub maps the advisory to a CVE. The command returns
`0` when no advisory meets the configured threshold, `1` when at least one does, and `2` when the audit cannot be
completed reliably. With Deno, `--no-dev` is rejected because `deno audit` cannot exclude development dependencies.
completed reliably. With Deno and the `native` manager, `--no-dev` is rejected because neither can exclude
development dependencies.
See the [usage guide](docs/usage.md#security-auditing) for formats and CI examples.

## Quick start
Expand Down
95 changes: 95 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,100 @@
# Upgrading Foxy

## Upgrading from 0.3.x to 0.4.x

Foxy 0.4 makes every manager auditable, resolves the frontend manager on first use, moves the asset rollback into the
solver, and removes utilities that Composer `^2.10` made redundant. Projects that only configure Foxy through
`composer.json` need no change beyond the constraint; authors of custom managers, audit parsers, or CVE resolvers must
follow the sections below.

### Composer constraint

```bash
composer require php-forge/foxy:^0.4 --with-all-dependencies
```

`symfony/console` `^8.0` is accepted beside `^7.4`; PHP 8.3 projects keep resolving `7.4`.

### Every manager audits

`Foxy\Audit\AuditableAssetManagerInterface` and `Foxy\Asset\AbstractAuditableAssetManager` were removed.
`AssetManagerInterface` now declares `audit(bool $noDev): AuditProcessResult` and
`getAuditParser(): AuditParserInterface`, and `AbstractAssetManager` carries the process-based implementation that the
former base class held (`getAuditCommand()`, `getAuditEnvironment()`, `validateAuditConfiguration()`). Custom managers
that extended `AbstractAuditableAssetManager` extend `AbstractAssetManager` instead and add `getAuditParser()`; direct
`AssetManagerInterface` implementations must add both methods, throwing a `RuntimeException` from `audit()` when they
cannot audit. `Foxy\Audit\AuditParserFactory` was removed: the runner asks the manager for its parser.
`Foxy\Audit\Parser\BunAuditParser` now extends the new `AbstractBulkAdvisoryParser`, shared with the native manager's
`NativeAuditParser`; the message case `Message::AUDIT_BUN_PACKAGE_ADVISORIES_REQUIRED` is now
`AUDIT_BULK_PACKAGE_ADVISORIES_REQUIRED`, and `AUDIT_PARSER_UNSUPPORTED_MANAGER` and `FOXY_AUDIT_UNSUPPORTED_MANAGER`
were removed.

The `native` manager now supports `composer foxy:audit` through the registry's bulk advisories endpoint; `--no-dev`
is rejected for it, as for Deno, because the native manager installs development dependencies together with the
others. `Foxy\Asset\NativeManager` takes the `NpmRegistryInterface` as its last constructor argument, and
`NpmRegistryInterface` gained `getAdvisories(array $versions): string` and
`prefetchTarballs(array $packages, string $directory): void`;
custom registries must implement both. The registry now uses the HTTP downloader of Composer's loop, so the
parallel downloads need no configuration.

### CVE lookups resolve in batches

`Foxy\Audit\CveResolverInterface::resolve(string): CveResolution` was replaced by
`resolveMany(array $ghsaIds): array`, which returns a `CveResolution` or a `Throwable` per identifier.
`GitHubAdvisoryCveResolver` issues its requests concurrently through Composer's HTTP downloader and therefore needs the
async-enabled downloader (`Composer::getLoop()->getHttpDownloader()`), which `composer foxy:audit` already uses.
`CveEnricher` keeps its constructor and `enrich()` signature.

### Manager selection timing

Foxy no longer probes manager executables when Composer loads the plugin. The manager is resolved when the first
install, update, or `composer foxy:audit` needs it, so Composer commands that do none of these (`about`, `show`,
`validate`, ...) never run `<manager> --version`. Selection errors (an unknown `manager`, several native lockfiles, a
lockfile whose executable is missing, or no manager at all) are reported by the first command that needs the manager
instead of by every Composer command; `composer foxy:audit` reports them with exit status `2`.
Comment thread
terabytesoftw marked this conversation as resolved.

### Manager-scoped configuration

`Foxy\Config\Config::setResolvedManager()` was removed. Probing or validating a manager no longer changes the shared
`Config`; `Config::forManager(string $manager): static` returns a copy whose `manager-*` map entries resolve for that
manager, and `AbstractManifestAssetManager::getManagerConfig()` (protected) gives each manager its own view, built
on every call.
Custom managers that called `setResolvedManager()` remove the call and read `manager-*` keys through
`getManagerConfig()`.
Comment thread
terabytesoftw marked this conversation as resolved.

### Default commands in `AbstractAssetManager`

`getInstallCommand()`, `getUpdateCommand()`, and `getVersionCommand()` are no longer abstract. They default to
`<binary> install`, `<binary> update`, and `<binary> --version`, where the binary comes from the new protected
`getDefaultBinary(): string` hook, which returns `getName()`. Custom managers whose executable differs from their name
override `getDefaultBinary()`; managers that implement the three methods keep working unchanged.

### Asset rollback belongs to the solver

`AssetManagerInterface::setFallback()` was removed, together with the fallback constructor argument, the protected
`$fallback` property, and `restoreAfterFailure()` of `AbstractAssetManager`; its constructor is now
`(IOInterface $io, Config $config, ProcessExecutor $executor, Filesystem $fs, VersionConverterInterface|null
$versionConverter = null)`, so a custom manager that passed a fallback must drop that argument.
`Foxy\Solver\Solver` takes the asset fallback as its last constructor argument and restores the manifest when the
dependency merge or the manager run throws or returns a non-zero code, before the `foxy.post-solve` event, as the
managers did; a listener of that event that throws no longer triggers an asset restore, only the Composer one.

### Composer rollback events

The rollback `Installer` is now built with a private event dispatcher that has no listeners and runs no scripts, so
command and autoload events of the rollback never reach the project's scripts or other plugins. Package events
(`pre-package-install`, `post-package-update`, ...) are still dispatched by Composer's shared installation manager
during a rollback, so root scripts and plugin listeners bound to those events run; projects that must not run them
during a rollback should guard those scripts.

### Removed utilities

`Foxy\Util\LockerUtil` was removed; the Composer fallback reads the lock through `Composer\Factory::getLockFile()`.
`Foxy\Util\ComposerUtil::getInitEventName()` was removed; use `Composer\Installer\InstallerEvents::PRE_OPERATIONS_EXEC`.
`Foxy\Util\PackageUtil::convertLockAlias()` and the `$convertAliases` parameter of `loadLockPackages()` were removed.
`Foxy\Json\JsonFile::getIndent()`, `Foxy\Json\JsonFormatter::getIndent()`, and the constant
`JsonFormatter::INDENT_REGEX` were removed.

## Upgrading from 0.2.x to 0.3.x

Foxy 0.3 raises the runtime baseline and aligns its documented integration target with the PHP 8.3 development line.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"composer/pcre": "^3.3",
"composer/semver": "^3.4",
"seld/jsonlint": "^1.12",
"symfony/console": "^7.4"
"symfony/console": "^7.4 || ^8.0"
},
"require-dev": {
"infection/infection": "^0.35",
Expand Down
31 changes: 24 additions & 7 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ selected by availability; it is used only when `manager` is `native` or when `fo
lockfile. Commit the lockfile generated by the selected manager. Foxy reports an error when an explicitly configured
manager is unknown, or when execution is enabled and its executable is unavailable.

Foxy resolves the manager when the first install, update, or audit needs it, not when Composer loads the plugin, so
Composer commands that do none of these (for example `about`, `show`, or `validate`) never probe an executable.
Selection errors are therefore reported by the first command that needs the manager.

When `run-asset-manager` is `false`, automatic selection does not probe executables. Foxy uses the manager identified
by a single recognized lockfile, or npm as the manifest adapter when no lockfile exists. Multiple lockfiles still
require an explicit `manager` value.
Expand Down Expand Up @@ -316,7 +320,11 @@ path is built from it. Scoped packages keep the npm layout (`vendor/npm-asset/@p
The native manager requires the PHP `zlib` extension and prints `Installing`, `Updating`, and `Removing` lines like
the other managers. Its registry requests go through Composer's HTTP layer: proxies, `cafile`, `disable-tls`, and the
credentials stored in `auth.json` for the registry host all apply, so a private registry can be configured with
`registry-url` plus Composer authentication:
`registry-url` plus Composer authentication. Credentials are matched against the request's `host:port` exactly as
written in `registry-url`: `localhost` and `127.0.0.1` are not aliases of each other, and the `auth.json` key must
include the port when it is not the scheme's default (`npm.example.com:4873`). A plain `http://` registry also needs
Composer's `config.secure-http` set to `false`. A registry that answers 401 or 403 fails the request with Composer's
error text, as any other registry failure does:

```json
{
Expand All @@ -330,8 +338,15 @@ credentials stored in `auth.json` for the registry host all apply, so a private
```

The registry is expected to answer the npm metadata API (`GET /<name>` with
`Accept: application/vnd.npm.install-v1+json`) and to serve the tarball URLs it publishes. HTTP 429 responses are
retried up to three times, honoring `Retry-After` up to 60 seconds; other failures are reported immediately.
`Accept: application/vnd.npm.install-v1+json`) and to serve the tarball URLs it publishes. `composer foxy:audit` also
needs npm's bulk advisories endpoint (`POST /-/npm/v1/security/advisories/bulk` with a JSON body); the registry must
implement it, and a registry that does not fails the audit with status `2`. Verdaccio implements it only with its
`audit` middleware enabled (`middlewares: audit: enabled: true`); its default configuration answers 404. HTTP 429
responses are retried up to three times, honoring `Retry-After` up to 60 seconds; other failures are reported
immediately. Tarballs missing from the cache are first downloaded in parallel through Composer's HTTP downloader
(`COMPOSER_MAX_PARALLEL_HTTP` sets the limit, 12 by default) and then verified and installed one by one; a tarball
whose parallel download fails is requested again on its own, with the retries above, and its error is reported then.
With `cache-read-only` or an unwritable cache, tarballs are downloaded one by one only.

The native manager implements the subset that frontend assets need. It does not support:

Expand All @@ -347,7 +362,6 @@ The native manager implements the subset that frontend assets need. It does not
named `alpha`, `beta`, `RC`, `dev`, and `patch` are matched with npm's rule: only a range that names a prerelease
of the same `major.minor.patch` can select it.
- Symbolic and hard links inside tarballs (skipped) and `..` or absolute entry paths (rejected).
- `composer foxy:audit`.

Selection by availability is also disabled for this manager: without `manager: native` or a `foxy.lock` file, a
project without a JavaScript manager still reports `No asset manager was found.`.
Expand Down Expand Up @@ -400,12 +414,15 @@ Both fallbacks are enabled by default. They can be controlled independently:
}
```

- `fallback-asset` restores the project `package.json` when package merging or manager execution fails.
- `fallback-asset` restores the project `package.json` when package merging fails, or when manager execution throws or
returns a non-zero result.
- `fallback-composer` restores the captured lock data and installed Composer dependencies for any exception or non-zero
manager result during asset solving.

Foxy rethrows the original solve error when Composer restoration succeeds. If restoration also fails, Foxy reports the
rollback failure and retains the original error as the previous exception. Disabled fallbacks do not create snapshots.
Foxy restores `package.json` first and Composer state second, and attempts the Composer restoration even when the
`package.json` restoration fails. It rethrows the original solve error when restoration succeeds. If a restoration also
fails, Foxy reports the rollback failure and retains the original error as the previous exception. Disabled fallbacks
do not create snapshots.

Composer lock and vendor state are captured at Composer's `pre-operations-exec` event. This is not a fully atomic
transaction for `composer require` or `composer remove`: Composer may update the root `composer.json` before that event,
Expand Down
4 changes: 3 additions & 1 deletion docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ The path passed to `addAsset()` may identify a package definition file, which Fo

`PostSolveEvent` exposes the common getters and `getRunResult()`, which returns the frontend manager process exit code.
The event is dispatched before Foxy attempts to restore captured Composer lock and vendor state after a non-zero
result. It is not dispatched when manager execution throws before returning a result.
result; by then `package.json` has already been restored when `fallback-asset` is enabled. It is not dispatched when
manager execution throws before returning a result. An exception thrown by a listener restores Composer state only, not
`package.json`.

## Subscriber example

Expand Down
23 changes: 13 additions & 10 deletions docs/faqs.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ Set `config.foxy.manager` to `bun`, `deno`, `native`, `npm`, `pnpm`, or `yarn` f
omitted and manager execution is enabled, Foxy first looks for one recognized lockfile and then for an available
manager executable. With execution disabled, it uses a single recognized lockfile or npm as the manifest adapter
without probing binaries. Multiple recognized lockfiles require explicit selection in either mode. The `native`
manager is selected only explicitly or through its `foxy.lock` file, never by availability.
manager is selected only explicitly or through its `foxy.lock` file, never by availability. The choice is made when the
first install, update, or audit needs a manager, so other Composer commands neither probe executables nor report
selection errors.

Explicit selection and a committed lockfile are recommended for CI.

Expand All @@ -50,20 +52,21 @@ Explicit selection and a committed lockfile are recommended for CI.
Yes. Set `config.foxy.manager` to `native`. Foxy reads the merged `package.json`, resolves every dependency against
the npm registry with one version per package, downloads and verifies the tarballs, extracts them into `node_modules`,
and records the result in `foxy.lock`. PHP with the `zlib` extension is the only requirement. The native manager does
not run lifecycle scripts, does not create `bin` links, and does not support `composer foxy:audit`. See
[Native manager](config.md#native-manager) for the complete scope.
not run lifecycle scripts and does not create `bin` links. See [Native manager](config.md#native-manager) for the
complete scope.

## How can frontend dependencies be checked for known CVEs?

Run `composer foxy:audit`. Foxy audits the dependency graph from the selected manager's native lockfile. The default
Run `composer foxy:audit`. Foxy audits the dependency graph from the selected manager's own lockfile. The default
table, plain, and JSON formats identify the affected package, advisory, severity, vulnerable range, and available CVE
identifiers. Use `--format=summary` for compact counts in CI or `--format=json` for a versioned machine-readable
document.

The native registry response commonly contains a GHSA but no CVE. Foxy resolves that relationship through GitHub's
public advisory API unless `--no-cve` or `--format=summary` is supplied. A lookup failure is shown as unavailable
without hiding the native security finding. See [Security auditing](usage.md#security-auditing) for options and exit
statuses.
The registry's audit response commonly contains a GHSA but no CVE. Foxy resolves that relationship through GitHub's
public advisory API unless `--no-cve` or `--format=summary` is supplied. The lookups run concurrently through
Composer's HTTP downloader (at most 12 at once; `COMPOSER_MAX_PARALLEL_HTTP` changes the limit), and a `github-oauth`
token in Composer's `auth.json` applies to them. A lookup failure marks only its own advisory as unavailable without
hiding the security finding itself. See [Security auditing](usage.md#security-auditing) for options and exit statuses.

## Why is a dependency's package.json not detected?

Expand All @@ -81,8 +84,8 @@ during Composer install and update operations.

When the manager throws or returns any non-zero status, Foxy reports the error and, by default, restores the previous
project `package.json`, Composer lock data, and installed Composer dependencies. The original manager error is rethrown
when restoration succeeds. If Composer restoration also fails, the rollback error retains the original error as its
previous exception.
when restoration succeeds. If a restoration also fails, the rollback error retains the original error as its previous
exception.

Set `fallback-asset` or `fallback-composer` to `false` only when retaining partial state is intentional. A Composer
rollback installer failure is reported as a `RuntimeException` rather than being hidden.
Expand Down
Loading
Loading