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 @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- feat: prepare Foxy `0.3` for PHP `8.3`, faster execution, safer fallbacks, updated tooling, and clearer docs.
- fix: preserve plugin self-updates and clarify framework-agnostic Composer application support.
- feat!: require Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0` and remove legacy manager support.
- fix: run manager commands in the configured root directory without changing the PHP working directory, and prevent manager probes and npm dependency cleanup when manager execution is disabled.

## 0.2.0 January 24, 2026

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@

- PHP 8.3 or later.
- Composer 2.10.2 or later.
- One supported frontend manager:
- One supported frontend manager when manager execution is enabled:
- Bun `^1.4.0`.
- npm `^12.0.2` with Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`.
- pnpm `^11.23.0` with Node.js `>=22.13.0`.
Expand Down Expand Up @@ -71,9 +71,12 @@ Selecting a manager explicitly is recommended for reproducible local and CI beha
}
```

Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When `manager` is omitted, Foxy first looks for one
recognized native lockfile and then checks available executables. Configure the manager explicitly when the project
contains lockfiles from more than one manager.
Valid manager values are `bun`, `npm`, `pnpm`, and `yarn`. When manager execution is enabled and `manager` is omitted,
Foxy first looks for one recognized native lockfile and then checks available executables. Configure the manager
explicitly when the project contains lockfiles from more than one manager.

When `run-asset-manager` is `false`, Foxy does not require or probe a manager binary. Automatic selection uses the
single recognized lockfile when present, or npm as the manifest adapter when no lockfile exists.

## Quick start

Expand Down
11 changes: 6 additions & 5 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Before updating, ensure the environment provides:

- PHP 8.3 or later.
- Composer 2.10.2 or later.
- One supported frontend manager: Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0`.
- One supported frontend manager when manager execution is enabled: Bun `^1.4.0`, npm `^12.0.2`, pnpm `^11.23.0`, or Yarn `^4.18.0`.
- For npm, Node.js `^22.22.2 || ^24.15.0 || >=26.0.0`.
- For pnpm, Node.js `>=22.13.0`.
- For Yarn, Node.js `>=18.12.0` on a release that still receives security updates.
Expand Down Expand Up @@ -48,9 +48,9 @@ Library authors that keep Foxy in `require-dev` should update that constraint to

### Frontend manager selection

Foxy can select a manager automatically from one recognized native lockfile or an available executable. Multiple
recognized lockfiles require explicit selection. For predictable upgrades and CI runs, configure the manager and
commit its native lockfile:
When manager execution is enabled, Foxy can select a manager automatically from one recognized native lockfile or an
available executable. Multiple recognized lockfiles require explicit selection. For predictable upgrades and CI runs,
configure the manager and commit its native lockfile:

```json
{
Expand Down Expand Up @@ -186,4 +186,5 @@ pre-command contents of the root `composer.json`, which Composer may already hav
when necessary, revert the root manifest after a failed operation.

Setting `enabled=false` now bypasses manager discovery, fallback snapshots, package merging, and manager execution.
Setting `run-asset-manager=false` retains package merging but skips manager version validation and execution.
Setting `run-asset-manager=false` retains package merging but skips manager binary probing, version validation,
execution, and npm cleanup of existing `node_modules/@composer-asset/*` installations.
18 changes: 13 additions & 5 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ manager-prefixed environment variable should contain the scalar value for the ac
| `manager-install-options` | string or map | Empty | Appends options only to install commands. |
| `manager-update-options` | string or map | Empty | Appends options only to update commands. |
| `manager-timeout` | integer or map | No practical limit | Sets the manager process timeout in seconds. |
| `run-asset-manager` | boolean | `true` | Controls manager execution while retaining package merging. |
| `run-asset-manager` | boolean | `true` | Controls manager probing and execution while retaining package merging. |
| `fallback-asset` | boolean | `true` | Restores `package.json` after asset processing fails. |
| `fallback-composer` | boolean | `true` | Restores Composer lock and vendor state after asset solving fails. |
| `composer-asset-dir` | string or `null` | `<vendor-dir>/php-forge/composer-asset/` | Sets the mock package directory. |
Expand Down Expand Up @@ -93,11 +93,15 @@ Set the manager explicitly when local development and CI must always use the sam
When `manager` is `null`, Foxy looks for one recognized native lockfile. Multiple recognized lockfiles require explicit
selection. Without a lockfile, available executables are considered in this order: npm, pnpm, Yarn, and Bun. Commit the
native lockfile generated by the selected manager. Foxy reports an error when an explicitly configured manager is
unknown or its executable is unavailable.
unknown, or when execution is enabled and its executable is unavailable.

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.

## Manager version constraints

Foxy always validates the selected manager against its built-in supported constraint:
When manager execution is enabled, Foxy validates the selected manager against its built-in supported constraint:

| Manager | Built-in constraint |
| ------- | ------------------- |
Expand All @@ -109,7 +113,7 @@ Foxy always validates the selected manager against its built-in supported constr
The `manager-version` option adds another Composer constraint that is evaluated together with the built-in constraint.
It can narrow the accepted versions for a project, but it cannot replace or widen Foxy's supported range.
Foxy treats the reported value as one concrete release and validates it from `root-package-json-dir` before every
manager command. When `run-asset-manager` is `false`, Foxy neither executes nor validates the manager binary.
manager command. When `run-asset-manager` is `false`, Foxy neither probes, executes, nor validates the manager binary.

Narrow the npm constraint for one project:

Expand Down Expand Up @@ -191,7 +195,7 @@ Set a timeout in seconds for the frontend manager process:

## Package merging without manager execution

Disable manager validation and execution while continuing to update `package.json`:
Disable manager binary probing and execution while continuing to update `package.json`:

```json
{
Expand All @@ -203,6 +207,10 @@ Disable manager validation and execution while continuing to update `package.jso
}
```

In this manifest-only mode, Foxy does not probe manager binaries, run install or update commands, or remove existing
`node_modules/@composer-asset/*` installations during npm reconciliation. Generated Composer asset manifests and the
root `package.json` are still updated.

## Fallbacks

Both fallbacks are enabled by default. They can be controlled independently:
Expand Down
14 changes: 8 additions & 6 deletions docs/faqs.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ different location.

## How does Foxy select a frontend manager?

Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` for deterministic selection. When it is omitted, Foxy first
looks for one recognized native lockfile and then for an available manager executable. Multiple recognized lockfiles
require explicit selection.
Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` for deterministic selection. When it is omitted and manager
execution is enabled, Foxy first looks for one recognized native 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.

Explicit selection and a committed native lockfile are recommended for CI.

Expand All @@ -49,7 +50,7 @@ Check the following:
1. The package uses one of the documented activation methods.
2. Its `package.json` exists at the package root or configured Foxy root.
3. The root application's `enable-packages` configuration does not exclude it.
4. The selected frontend manager is installed and allowed by its configured version constraint.
4. When manager execution is enabled, the selected frontend manager is installed and allowed by its configured version constraint.

Composer must run before a standalone frontend manager command because Foxy creates the local package representations
during Composer install and update operations.
Expand All @@ -76,8 +77,9 @@ operation; enabled fallbacks restore their captured state when asset solving fai

## Can Foxy update package.json without installing frontend dependencies?

Yes. Set `config.foxy.run-asset-manager=false`. Foxy will update the package definition but skip the external manager
validation and command.
Yes. Set `config.foxy.run-asset-manager=false`. Foxy will update the package definition without probing or validating
the external manager, running its commands, or removing existing `node_modules/@composer-asset/*` installations during
npm reconciliation.

## Why are a dependency's scripts or devDependencies not copied?

Expand Down
12 changes: 9 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ package version only when the embedded package does not declare its own version.
| Yarn | `^4.18.0`; Node.js `>=18.12.0` on a release that still receives security fixes |
| Git | Required only for Git-based dependencies |

The frontend manager requirements apply only when manager execution is enabled. Manifest-only mode does not require a
manager binary.

## Installation

Composer plugins execute code during Composer operations. Authorize Foxy explicitly before installing it:
Expand All @@ -40,9 +43,12 @@ The plugin is installed in the configured Composer vendor directory, normally `v

## Manager selection

Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` when reproducible manager selection is required. When the
option is omitted, Foxy looks for one recognized native lockfile and then for an available manager executable. Multiple
recognized lockfiles require explicit selection.
Set `config.foxy.manager` to `bun`, `npm`, `pnpm`, or `yarn` when reproducible manager selection is required. When
manager execution is enabled and the option is omitted, Foxy looks for one recognized native lockfile and then for an
available manager executable. Multiple recognized lockfiles require explicit selection.

When `run-asset-manager` is `false`, Foxy selects from a single lockfile or uses npm as the manifest adapter without
probing executables.

Commit the selected manager's native lockfile and use the same explicit manager in local development and CI.

Expand Down
89 changes: 42 additions & 47 deletions src/Asset/AbstractAssetManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ public function addDependencies(RootPackageInterface $rootPackage, array $depend

$alreadyInstalledDependencies = $assetPackage->addNewDependencies($dependencies);

$this->actionWhenComposerDependenciesAreAlreadyInstalled($alreadyInstalledDependencies);
if ($this->config->isEnabled('run-asset-manager')) {
$this->actionWhenComposerDependenciesAreAlreadyInstalled($alreadyInstalledDependencies);
}

$this->io->write('<info>Merging Composer dependencies in the asset package</info>');

return $assetPackage->write();
Expand Down Expand Up @@ -129,61 +132,35 @@ public function run(): int

$this->validate();

$rootPackageDir = $this->getManagerWorkingDirectory();
$managerWorkingDirectory = $this->getManagerWorkingDirectory();
$updatable = $this->isUpdatable();

$originalDir = null;
$changedDir = false;
$info = sprintf('<info>%s %s dependencies</info>', $updatable ? 'Updating' : 'Installing', $this->getName());

if (null !== $rootPackageDir) {
$originalDir = getcwd();
$this->io->write($info);

if (false === $originalDir) {
throw new RuntimeException('Unable to get the current working directory.');
}
$timeout = ProcessExecutor::getTimeout();

if (chdir($rootPackageDir) === false) {
throw new RuntimeException(sprintf('Unable to change working directory to "%s".', $rootPackageDir));
}
/** @var int $managerTimeout */
$managerTimeout = $this->config->get('manager-timeout', PHP_INT_MAX);

$changedDir = true;
}
ProcessExecutor::setTimeout($managerTimeout);

try {
$updatable = $this->isUpdatable();

$info = sprintf('<info>%s %s dependencies</info>', $updatable ? 'Updating' : 'Installing', $this->getName());

$this->io->write($info);

$timeout = ProcessExecutor::getTimeout();

/** @var int $managerTimeout */
$managerTimeout = $this->config->get('manager-timeout', PHP_INT_MAX);

ProcessExecutor::setTimeout($managerTimeout);

try {
$cmd = $updatable ? $this->getUpdateCommand() : $this->getInstallCommand();
$res = $this->executor->execute($cmd);
} catch (Throwable $exception) {
$this->restoreAfterFailure($exception);

throw $exception;
} finally {
ProcessExecutor::setTimeout($timeout);
}
$cmd = $updatable ? $this->getUpdateCommand() : $this->getInstallCommand();
$res = $this->executeManagerCommand($cmd, $managerWorkingDirectory);
} catch (Throwable $exception) {
$this->restoreAfterFailure($exception);

if (0 !== $res && null !== $this->fallback) {
$this->restoreAfterFailure(
new RuntimeException(sprintf('The asset manager exited with status code %d.', $res), $res),
);
}
throw $exception;
} finally {
if ($changedDir && chdir($originalDir) === false) {
throw new RuntimeException(
sprintf('Unable to restore working directory to "%s".', $originalDir),
);
}
ProcessExecutor::setTimeout($timeout);
}

if (0 !== $res && null !== $this->fallback) {
$this->restoreAfterFailure(
new RuntimeException(sprintf('The asset manager exited with status code %d.', $res), $res),
);
}

return $res;
Expand Down Expand Up @@ -353,6 +330,24 @@ protected function getVersion(): string|null
return $this->version;
}

/**
* Execute a manager command without changing the PHP process working directory.
*/
private function executeManagerCommand(string $command, string|null $workingDirectory): int
{
$outputHandler = function (string $type, string $buffer): void {
if ('err' === $type) {
$this->io->writeErrorRaw($buffer, false);

return;
}

$this->io->writeRaw($buffer, false);
};

return $this->executor->execute($command, $outputHandler, $workingDirectory);
}

private function getManagerWorkingDirectory(): string|null
{
$rootPackageDir = $this->config->get('root-package-json-dir');
Expand Down
13 changes: 7 additions & 6 deletions src/Asset/AssetManagerFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,12 @@ public function addManager(AssetManagerInterface $manager): void
* Find the asset manager.
*
* @param string|null $manager The name of the asset manager
* @param bool $checkAvailability Whether to check automatically selected manager availability
*
* @throws RuntimeException When the asset manager does not exist
* @throws RuntimeException When the asset manager is not found
*/
public function findManager(string|null $manager = null): AssetManagerInterface
public function findManager(string|null $manager = null, bool $checkAvailability = true): AssetManagerInterface
{
if (null !== $manager) {
if (isset($this->managers[$manager])) {
Expand All @@ -49,15 +50,15 @@ public function findManager(string|null $manager = null): AssetManagerInterface
throw new RuntimeException(sprintf('The asset manager "%s" doesn\'t exist', $manager));
}

return $this->findAvailableManager();
return $this->findAvailableManager($checkAvailability);
}

/**
* Find the available asset manager.
*
* @throws RuntimeException When no asset manager is found
*/
private function findAvailableManager(): AssetManagerInterface
private function findAvailableManager(bool $checkAvailability): AssetManagerInterface
{
$lockedManagers = [];

Expand All @@ -73,7 +74,7 @@ private function findAvailableManager(): AssetManagerInterface
}

if (isset($lockedManagers[0])) {
if ($lockedManagers[0]->isAvailable()) {
if (!$checkAvailability || $lockedManagers[0]->isAvailable()) {
return $lockedManagers[0];
}

Expand All @@ -85,9 +86,9 @@ private function findAvailableManager(): AssetManagerInterface
);
}

// Find the first available manager when no lockfile exists.
// Find the first manager when no lockfile exists, probing it only when requested.
foreach ($this->managers as $manager) {
if ($manager->isAvailable()) {
if (!$checkAvailability || $manager->isAvailable()) {
return $manager;
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Foxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ private function getAssetManager(
/** @var string|null $manager */
$manager = $config->get('manager');

return $amf->findManager($manager);
return $amf->findManager($manager, $this->isEnabled('run-asset-manager'));
}

/**
Expand Down
Loading
Loading