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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
- feat!: keep installed native packages between runs, report native installs by verbosity, and drop the `$defaultBin` command parameter.

## 0.3.0 October 7, 2026

Expand Down
354 changes: 118 additions & 236 deletions UPGRADE.md

Large diffs are not rendered by default.

31 changes: 22 additions & 9 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,16 @@ Foxy then performs the installation itself, in PHP:
`cache-read-only` settings; every cached tarball is verified again before use.
- It writes `foxy.lock`, which records the root requirements and the requirements of every local package (regular
and optional ones apart), and the selected version, tarball URL, and integrity of every installed package. `composer install` with a lock that
matches the current manifests reinstalls exactly those tarballs without a single metadata request;
matches the current manifests installs exactly those tarballs without a single metadata request;
`composer update`, or a lock that no longer matches, resolves again and rewrites the lock. Commit `foxy.lock`.
- It records what it installed in `.foxy-installed.json` inside the install directory, the counterpart of npm's
`node_modules/.package-lock.json`: the `foxy.lock` entry of every package left in place. A registry package whose
directory exists and whose entry matches the lock is kept untouched, so a warm install extracts nothing; a package
whose entry differs or whose directory is missing is extracted again. `file:` packages are always copied again,
since their content can change without a version change. The file is removed at the start of each installation and
written at its end, so an interrupted installation leaves none behind. A missing or malformed file only forces every
registry package to be extracted again, as happens once on the first install that creates it; deleting it is always
safe. Like every dot-entry, the pruning leaves it alone. Do not commit it.
- It removes entries of the install directory that are not part of the installation (dot-entries such as `.bin`
are left alone).

Expand Down Expand Up @@ -317,14 +325,19 @@ followed by a name of letters, digits, `.`, `_`, and `-`, up to 214 characters);
path is built from it. Scoped packages keep the npm layout (`vendor/npm-asset/@popperjs/core`), not asset-packagist's
`popperjs--core` form.

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. 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:
The native manager requires the PHP `zlib` extension. Its output follows Composer's verbosity levels: at normal
verbosity it prints only changes (`Installing`, `Updating`, and `Removing` lines), or
`Nothing to install, update or remove`; `-v` adds a `Keeping` line per kept package and a `Refreshing` line per
unchanged `file:` package copied again; `-vv` adds the registry traffic (metadata requests, tarball downloads,
prefetches, and cache hits); `-vvv` adds Composer's own HTTP log.

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. 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 Down
30 changes: 15 additions & 15 deletions src/Asset/AbstractAssetManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,30 +147,30 @@ public function validate(): void
}

/**
* Build the command with binary and command options.
* Returns the manager command run by the configured or default binary, followed by the `manager-options` and
* `manager-{action}-options` values.
*
* @param string $defaultBin The default binary of command if option isn't defined.
* @param string $action The command action to retrieve the options in config.
* @param array|string $command The command.
* @param string $action The action whose `manager-{action}-options` value is appended.
* @param array|string $command The command arguments, joined with spaces.
*/
protected function buildCommand(string $defaultBin, string $action, array|string $command): string
protected function buildCommand(string $action, array|string $command): string
{
$gOptions = trim((string) $this->getManagerConfig()->get('manager-options', ''));
$options = trim((string) $this->getManagerConfig()->get("manager-{$action}-options", ''));

return $this->buildUnconfiguredCommand($defaultBin, $command)
return $this->buildUnconfiguredCommand($command)
. ($gOptions === '' ? '' : " {$gOptions}")
. ($options === '' ? '' : " {$options}");
}

/**
* Build a manager command without inheriting install and update options.
* Returns the manager command run by the configured or default binary, without the install and update options.
*
* @param array|string $command The command.
* @param array|string $command The command arguments, joined with spaces.
*/
protected function buildUnconfiguredCommand(string $defaultBin, array|string $command): string
protected function buildUnconfiguredCommand(array|string $command): string
{
return sprintf('%s %s', $this->getManagerBinary($defaultBin), implode(' ', (array) $command));
return sprintf('%s %s', $this->getManagerBinary(), implode(' ', (array) $command));
}

/**
Expand All @@ -197,7 +197,7 @@ protected function getDefaultBinary(): string
*/
protected function getInstallCommand(): string
{
return $this->buildCommand($this->getDefaultBinary(), 'install', 'install');
return $this->buildCommand('install', 'install');
}

/**
Expand All @@ -206,7 +206,7 @@ protected function getInstallCommand(): string
*/
protected function getUpdateCommand(): string
{
return $this->buildCommand($this->getDefaultBinary(), 'update', 'update');
return $this->buildCommand('update', 'update');
}

protected function getVersion(): string|null
Expand All @@ -233,7 +233,7 @@ protected function getVersion(): string|null
*/
protected function getVersionCommand(): string
{
return $this->buildUnconfiguredCommand($this->getDefaultBinary(), '--version');
return $this->buildUnconfiguredCommand('--version');
}

/**
Expand Down Expand Up @@ -293,10 +293,10 @@ private function executeManagerCommand(string $command, string|null $workingDire
return $this->executor->execute($command, $outputHandler, $workingDirectory);
}

private function getManagerBinary(string $defaultBin): string
private function getManagerBinary(): string
{
/** @var string $bin */
$bin = $this->getManagerConfig()->get('manager-bin', $defaultBin);
$bin = $this->getManagerConfig()->get('manager-bin', $this->getDefaultBinary());

return Platform::isWindows() ? str_replace('/', '\\', $bin) : $bin;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Asset/BunManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ protected function getAuditCommand(bool $noDev): string
$command[] = '--prod';
}

return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command);
return $this->buildUnconfiguredCommand($command);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Asset/DenoManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ protected function actionWhenComposerDependenciesAreMerged(

protected function getAuditCommand(bool $noDev): string
{
return $this->buildUnconfiguredCommand($this->getDefaultBinary(), ['audit', '--level=low']);
return $this->buildUnconfiguredCommand(['audit', '--level=low']);
}

protected function getAuditEnvironment(): array
Expand All @@ -116,7 +116,7 @@ protected function getDefaultBinary(): string

protected function getUpdateCommand(): string
{
return $this->buildCommand($this->getDefaultBinary(), 'update', ['update', '--lockfile-only', '--recursive'])
return $this->buildCommand('update', ['update', '--lockfile-only', '--recursive'])
. ' && '
. $this->getInstallCommand();
}
Expand Down
2 changes: 1 addition & 1 deletion src/Asset/NpmManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ protected function getAuditCommand(bool $noDev): string

$command = [...$command, $noDev ? '--omit=dev' : '--include=dev', '--include=optional', '--include=peer'];

return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command);
return $this->buildUnconfiguredCommand($command);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Asset/PnpmManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ protected function getAuditCommand(bool $noDev): string
ProcessExecutor::escape('--config.auditConfig={ignoreGhsas:[]}'),
];

return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command);
return $this->buildUnconfiguredCommand($command);
}
}
4 changes: 2 additions & 2 deletions src/Asset/YarnManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected function getAuditCommand(bool $noDev): string
$command = [...$command, '--environment', 'production'];
}

return $this->buildUnconfiguredCommand($this->getDefaultBinary(), $command);
return $this->buildUnconfiguredCommand($command);
}

protected function getAuditEnvironment(): array
Expand All @@ -62,6 +62,6 @@ protected function getAuditEnvironment(): array

protected function getUpdateCommand(): string
{
return $this->buildCommand($this->getDefaultBinary(), 'update', 'up');
return $this->buildCommand('update', 'up');
}
}
134 changes: 134 additions & 0 deletions src/Native/InstalledState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace Foxy\Native;

use Composer\IO\IOInterface;
use Composer\Json\JsonFile as ComposerJsonFile;
use Foxy\Json\JsonFile;
use Seld\JsonLint\ParsingException;

use function array_keys;
use function is_array;
use function is_file;
use function is_string;
use function ksort;
use function sprintf;

use const SORT_STRING;

/**
* Reads and writes the hidden state file of an install directory: the lock entry of every package the last successful
* native installation left in place, the counterpart of npm's `node_modules/.package-lock.json`.
*
* The state is a cache: a missing or malformed file reads as empty, so the installer extracts every registry package
* again.
*/
final readonly class InstalledState
{
private const string MALFORMED = '<warning>The installed state file "%s" is malformed and was ignored.</warning>';

/**
* @param string $path State file path.
* @param IOInterface $io Output for the verbose notice of a malformed file.
*/
public function __construct(private string $path, private IOInterface $io) {}

/**
* Returns the recorded lock entries keyed by package name, in the shape of {@see ResolvedPackage::toLock()}.
*
* A missing file reads as empty. A file that is not valid JSON, has no `packages` map, or holds an entry of
* another shape also reads as empty, with a verbose warning.
*
* Keys are package names; PHP stores a numeric name such as `123` as an `int` key.
*
* @return array<array-key, array<string, string>>
*/
public function read(): array
{
if (!is_file($this->path)) {
return [];
}

$entries = $this->decode()['packages'] ?? null;

if (!is_array($entries)) {
return $this->malformed();
}

$state = [];

foreach ($entries as $name => $entry) {
if (!self::isEntry($entry)) {
return $this->malformed();
}

$state[$name] = $entry;
}

return $state;
}

/**
* Writes the state file as `{"packages": {...}}`, with the names sorted byte-wise.
*
* @param array<array-key, array<string, string>> $entries Lock entries keyed by package name.
*/
public function write(array $entries): void
{
ksort($entries, SORT_STRING);

(new JsonFile($this->path))->write(['packages' => (object) $entries]);
}

/**
* Returns the decoded state file, or `null` when it is not valid JSON.
*/
private function decode(): mixed
{
try {
return (new ComposerJsonFile($this->path))->read();
} catch (ParsingException) {
return null;
}
}

/**
* Returns whether a value is a lock entry: string `version`, `resolved`, and `integrity`, or `version` and `file`.
*
* @phpstan-assert-if-true array<string, string> $entry
*/
private static function isEntry(mixed $entry): bool
{
if (!is_array($entry)) {
return false;
}

$keys = array_keys($entry);

if ($keys !== ['version', 'resolved', 'integrity'] && $keys !== ['version', 'file']) {
return false;
}

foreach ($entry as $value) {
if (!is_string($value)) {
return false;
}
}

return true;
}

/**
* Writes the verbose warning of a malformed file and returns the empty state.
*
* @return array{}
*/
private function malformed(): array
{
$this->io->writeError(sprintf(self::MALFORMED, $this->path), true, IOInterface::VERBOSE);

return [];
}
}
Loading
Loading