Skip to content
Open
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
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,28 @@ An example `build.json` below shows three different usages:
}
```

Not a dependency manager
------------------------

This library assumes the configuration of the system is already configured.

The primary objective is to provide a client-side build system that is automatically configured for PHP projects, leaving the configuration of the system down to the developer's choice of client-side dependency management software.
Installing client-side packages
-------------------------------

For projects with a `package.json`, the build runner checks direct dependencies with
`npm ls --json --depth=0 --include=dev` before checking build requirements. Missing
packages or versions that do not satisfy `package.json` trigger `npm install
--include=dev` automatically, with progress shown in the terminal. Installation
must succeed and satisfy the dependency check before the build continues.
Extraneous packages alone do not trigger installation. This checks installed
dependencies, rather than whether `npm install` has ever been run; it does not
validate every transitive dependency or compare the installation against a lockfile.

The project's `node_modules/.bin` is added to PATH during the build, so commands
such as `webpack` and `sass` can use their locally installed binaries.

Install [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
first. See [npm's installation documentation](https://docs.npmjs.com/cli/commands/npm-install)
for installing the packages declared by your project. Missing build commands and
installation failures include these links in the terminal.

Projects without a `package.json` continue to use their chosen dependency manager
and installed tools without invoking npm.

Features at a glance
--------------------
Expand Down
58 changes: 46 additions & 12 deletions src/BuildRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
namespace GT\Build;

use Gt\Cli\Stream;
use Gt\Daemon\CommandNotFoundException;

/** Responsible for running all build tasks and optionally watching for changes */
class BuildRunner {
Expand Down Expand Up @@ -36,22 +37,55 @@ public function run(
$workingDirectory = $this->formatWorkingDirectory();
$jsonPath = $this->getJsonPath($workingDirectory);

$startTime = microtime(true);
$previousPath = getenv("PATH");
$previousCwd = getcwd();
$binPath = realpath($workingDirectory) . DIRECTORY_SEPARATOR . "node_modules"
. DIRECTORY_SEPARATOR . ".bin";
putenv("PATH=$binPath" . PATH_SEPARATOR . ($previousPath ?: ""));

// Check that the developer has all the necessary requirements.
// $errors will be passed by reference to Build::check. Passing an array by
// reference will suppress exceptions, instead filling the array with error
// strings for output back to the terminal.
$errors = [];
$build = $this->checkRequirements($jsonPath, $workingDirectory, $errors, $mode);
try {
$this->installClientSidePackages($workingDirectory);
$startTime = microtime(true);
$errors = [];
$build = $this->checkRequirements($jsonPath, $workingDirectory, $errors, $mode);

if(!empty($errors)) {
$this->showErrors($errors);
return;
if(!empty($errors)) {
$this->showErrors($errors);
$this->showInstallationHelp();
return;
}

$this->build($build, $continue);
$this->logElapsedTime($startTime);
}
catch(CommandNotFoundException $exception) {
$this->logMessage("Command not found: " . $exception->getMessage(), Stream::ERROR);
$this->showInstallationHelp();
}
catch(BuildException $exception) {
$this->logMessage($exception->getMessage(), Stream::ERROR);
$this->showInstallationHelp();
}
finally {
putenv($previousPath === false ? "PATH" : "PATH=$previousPath");
chdir($previousCwd);
}
}

protected function installClientSidePackages(string $workingDirectory):void {
$packages = new ClientSidePackages($workingDirectory, $this->stream);
$packages->installIfNeeded();
}

$this->build($build, $continue);
$this->logElapsedTime($startTime);
protected function showInstallationHelp():void {
$this->stream->writeLine(
"Install Node.js and npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm",
Stream::ERROR,
);
$this->stream->writeLine(
"Install your project's build tools with `npm install`: https://docs.npmjs.com/cli/commands/npm-install",
Stream::ERROR,
);
}

public function setDefaultPath(string $path):void {
Expand Down
89 changes: 89 additions & 0 deletions src/ClientSidePackages.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php
namespace GT\Build;

use Gt\Cli\Stream;
use Gt\Daemon\Process;

/** Uses npm's dependency validation rather than comparing file timestamps. */
class ClientSidePackages {
public function __construct(
protected string $workingDirectory,
protected Stream $stream,
) {}

public function installIfNeeded():void {
if(!is_file($this->workingDirectory . DIRECTORY_SEPARATOR . "package.json")) {
return;
}

if(!$this->needsInstallation()) {
return;
}

$this->stream->writeLine(
"Client-side packages are missing or do not match package.json, running `npm install`..."
);
[$exitCode] = $this->runCommand(["npm", "install", "--include=dev"], true);
if($exitCode !== 0 || $this->needsInstallation()) {
throw new BuildException(
"Client-side package installation failed. Run `npm install` in $this->workingDirectory."
);
}
}

/** @phpstan-impure */
protected function needsInstallation():bool {
[$exitCode, $output, $error] = $this->runCommand([
"npm", "ls", "--json", "--depth=0", "--include=dev",
]);
$result = json_decode($output, true);
if(!is_array($result)) {
throw new BuildException("Unable to check client-side packages: $error$output");
}

foreach($result["problems"] ?? [] as $problem) {
if(str_starts_with($problem, "missing:") || str_starts_with($problem, "invalid:")) {
return true;
}
}

// Extraneous packages do not require an install. Other npm errors must be
// reported rather than assuming an install will fix them.
if($exitCode !== 0 && empty($result["problems"])) {
throw new BuildException("Unable to check client-side packages: $error$output");
}
return false;
}

/**
* @param array<int, string> $command
* @return array{int|null, string, string}
*/
protected function runCommand(array $command, bool $showOutput = false):array {
$process = new Process(...$command);
$process->setExecCwd($this->workingDirectory);
$process->exec();
$output = "";
$error = "";
do {
$stdout = $process->getOutput();
$stderr = $process->getErrorOutput();
$output .= $stdout;
$error .= $stderr;
if($showOutput) {
$this->stream->write($stdout);
$this->stream->write($stderr, Stream::ERROR);
}
usleep(10000);
}
while($process->isRunning());
// Drain output written between the last read and process exit.
$stdout = $process->getOutput();
$stderr = $process->getErrorOutput();
if($showOutput) {
$this->stream->write($stdout);
$this->stream->write($stderr, Stream::ERROR);
}
return [$process->getExitCode(), $output . $stdout, $error . $stderr];
}
}
89 changes: 89 additions & 0 deletions test/phpunit/BuildRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,95 @@ public function testFormatWorkingDirectoryNormalisesFilePath():void {
self::assertSame($workingDirectory, $sut->exposedFormatWorkingDirectory());
}

public function testMissingCommandShowsDocumentationAndRestoresEnvironment():void {
$workingDirectory = $this->createTemporaryDirectory();
$this->createTemporaryFile($workingDirectory . "/source.js", "");
$this->createTemporaryFile($workingDirectory . "/build.ini", "[*.js]\nexecute=phpgt-missing-build-command unused\n");
$stream = $this->createStream();
$runner = new \GT\Build\BuildRunner($workingDirectory, $stream);
$previousCwd = getcwd();
$previousPath = getenv("PATH");

$runner->run(false);

self::assertSame($previousCwd, getcwd());
self::assertSame($previousPath, getenv("PATH"));
$stream->getErrorStream()->rewind();
$output = $stream->getErrorStream()->fread(4096);
self::assertStringContainsString("Command not found: phpgt-missing-build-command", $output);
self::assertStringContainsString("https://docs.npmjs.com/cli/commands/npm-install", $output);
}

public function testLocalBinaryIsAvailableToRequirementsAndBuild():void {
$workingDirectory = $this->createTemporaryDirectory();
foreach(["/node_modules", "/node_modules/.bin"] as $directory) {
mkdir($workingDirectory . $directory);
$this->temporaryPaths []= $workingDirectory . $directory;
}
$command = $workingDirectory . "/node_modules/.bin/phpgt-local-tool";
$this->createTemporaryFile($command, "#!" . PHP_BINARY . "\n<?php if(in_array('--version', \$argv)) { echo '1.2.3'; }");
chmod($command, 0755);
$this->createTemporaryFile($workingDirectory . "/source.js", "");
$this->createTemporaryFile($workingDirectory . "/build.ini",
"[*.js]\nexecute=phpgt-local-tool unused\nrequire=phpgt-local-tool ^1.0\n");
$stream = $this->createStream();
$runner = new \GT\Build\BuildRunner($workingDirectory, $stream);
$previousPath = getenv("PATH");

$runner->run(false);

self::assertSame($previousPath, getenv("PATH"));
$stream->getOutStream()->rewind();
self::assertStringContainsString("Success:", $stream->getOutStream()->fread(4096));
$stream->getErrorStream()->rewind();
self::assertSame("", $stream->getErrorStream()->fread(4096));
}

public function testInstallationFailurePreventsBuildAndShowsHelp():void {
$workingDirectory = $this->createTemporaryDirectory();
$this->createTemporaryFile($workingDirectory . "/build.ini", "");
$stream = $this->createStream();
$runner = new class($workingDirectory, $stream) extends \GT\Build\BuildRunner {
protected function installClientSidePackages(string $workingDirectory):void {
throw new BuildException("Client-side package installation failed");
}
protected function build(\GT\Build\Build $build, bool $continue = true):void {
throw new \LogicException("Build must not start after a failed install");
}
};
$runner->run(false);
$stream->getErrorStream()->rewind();
$output = $stream->getErrorStream()->fread(4096);
self::assertStringContainsString("Client-side package installation failed", $output);
self::assertStringContainsString("https://docs.npmjs.com/cli/commands/npm-install", $output);
}

public function testMissingNpmShowsNodeInstallationHelp():void {
$workingDirectory = $this->createTemporaryDirectory();
$this->createTemporaryFile($workingDirectory . "/build.ini", "");
$this->createTemporaryFile($workingDirectory . "/package.json", "{}");
$stream = $this->createStream();
$runner = new \GT\Build\BuildRunner($workingDirectory, $stream);
$previousPath = getenv("PATH");
putenv("PATH=" . $workingDirectory);
try {
$runner->run(false);
self::assertSame($workingDirectory, getenv("PATH"));
}
finally {
putenv($previousPath === false ? "PATH" : "PATH=$previousPath");
}
$stream->getErrorStream()->rewind();
$output = $stream->getErrorStream()->fread(4096);
self::assertStringContainsString("Command not found: npm", $output);
self::assertStringContainsString("https://docs.npmjs.com/downloading-and-installing-node-js-and-npm", $output);
}

private function createTemporaryFile(string $path, string $content):void {
file_put_contents($path, $content);
$this->temporaryPaths []= $path;
}

private function createTemporaryDirectory():string {
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid("phpgt-build-", true);
mkdir($path);
Expand Down
Loading
Loading