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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ All notable changes are documented here. The format is based on [Keep a Changelo
- Added `Crest\Command\Make\NamedArtifactCommand`: base of `make:command`, `make:middleware`, `make:provider` and `make:responder`.
- Added `serve` (alias `server`): runs `php -S 127.0.0.1:8080 -t public .htrouter.php` in the project root. Port: `--port`, then `APP_PORT` (environment or `.env`), then 8080. Fails if `.htrouter.php` or `vendor/autoload.php` is missing. [#10](https://github.com/phalcon/crest/issues/10)
- Added a package hint for unknown commands: `unknown command 'migration:run'; provided by phalcon/migrations`. No hint when a command with that prefix is registered. The map is set with `Crest\Console\Registry::withProviders()`. [#19](https://github.com/phalcon/crest/issues/19)
- Added the hand-off: in a project, a global crest passes each project command to the project's `vendor/bin/crest` and returns its exit status. `new`, `up`, `down`, `install` and `--version` stay in the global crest. A project that requires `phalcon/crest` without `vendor/bin/crest` gives an error. [#27](https://github.com/phalcon/crest/issues/27)
- Added `Crest\Project\Locator::project()`: the nearest directory with a `composer.json`. [#27](https://github.com/phalcon/crest/issues/27)

### Changed

Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@ directory must be in your `PATH`:

## Usage

In a project, use the crest in `vendor/`. The commands that work on the project need
the project autoloader and its Phalcon:

vendor/bin/crest list available commands
vendor/bin/crest about environment and version report
vendor/bin/crest make:action GET /company/all
Run `crest` in a project. The commands that work on the project need the project
autoloader and its Phalcon, so a global crest passes them to the project's
`vendor/bin/crest` and returns its exit status:

crest list available commands
crest about environment and version report
crest make:action GET /company/all

`new`, `up`, `down`, `install` and `--version` always run in the crest that you type.
Without a global crest, run `vendor/bin/crest` in the project. crest finds the project
from a subdirectory, or from `--directory`. If the project requires `phalcon/crest` but
has no `vendor/bin/crest` yet, crest stops and tells you to run `crest install` or
`composer install`.

To create a project, use the global crest. The new project requires `phalcon/crest`, so
after `composer install` it has its own `vendor/bin/crest`:
Expand Down
14 changes: 13 additions & 1 deletion bin/crest
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ declare(strict_types=1);

use Crest\Commands;
use Crest\Console\Kernel;
use Crest\Console\Output;
use Crest\HandOff;

// Dev checkout: bin/../vendor; installed dependency: vendor/phalcon/crest/bin -> vendor.
foreach ([dirname(__DIR__) . '/vendor/autoload.php', dirname(__DIR__, 3) . '/autoload.php'] as $autoload) {
Expand All @@ -29,6 +31,16 @@ if (!class_exists(Kernel::class)) {
exit(1);
}

$arguments = $_SERVER['argv'] ?? [];

// In a project, a global crest passes the project commands to the crest of
// the project.
$status = (new HandOff())->run($arguments, new Output());

if (null !== $status) {
exit($status);
}

$kernel = new Kernel(Commands::NAME, Commands::registry(), Commands::PACKAGE);

exit($kernel->handle($_SERVER['argv'] ?? []));
exit($kernel->handle($arguments));
24 changes: 17 additions & 7 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ same router and document root as the container. The port is `--port`, else
as docker compose does: the last `APP_PORT` line wins, and `export`,
`APP_PORT: <port>`, quotes and `#` comments are allowed. It does not expand
`${...}`. Such a value stops `serve` with an error. `serve` finds the root
from a subdirectory, and it does not need the crest in `vendor/`, so the
global crest runs it. It stops before PHP starts if `.htrouter.php` or
`vendor/autoload.php` is missing.
from a subdirectory. A global crest passes it to the crest in `vendor/`, as
it does the other project commands. It stops before PHP starts if
`.htrouter.php` or `vendor/autoload.php` is missing.

The server uses the PHP that runs crest. PHP options on the command line, for
example `-d extension=phalcon.so`, do not reach it. Put such settings in
Expand All @@ -86,11 +86,21 @@ them with a crest outside the project, for example one that you install with
The generated project requires `phalcon/crest` as a dev dependency. The
commands that work on the project, for example `make:action` and
`route:list`, need the autoloader and the Phalcon of the project. After
`crest install` or `composer install`, run them with the crest in `vendor/`:
`crest install` or `composer install`, the project has its own crest in
`vendor/`. A global crest passes these commands to it, with the same
arguments, and returns its exit status. In the container, there is no global
crest:

vendor/bin/crest make:action GET /hello
crest make:action GET /hello
docker compose exec app vendor/bin/crest make:action GET /hello

`new`, `up`, `down`, `install` and `--version` always run in the crest that
you type. crest finds the project from the working directory, or from
`--directory`, and goes up to the nearest `composer.json`. If that project
requires `phalcon/crest` but has no `vendor/bin/crest`, crest stops:

crest: /path/to/my-app has no vendor/bin/crest; run 'crest install' or 'composer install' first

The files come from the `project-*` stubs. To change them, publish them by
name in the directory that the project goes into (the working directory, or
`--directory`), then edit the copies:
Expand Down Expand Up @@ -170,8 +180,8 @@ reads the filesystem and keeps working on a project that does not currently run.

`make:action` takes an HTTP method and a route path:

vendor/bin/crest make:action GET /company/all
vendor/bin/crest make:action GET /company/{id}
crest make:action GET /company/all
crest make:action GET /company/{id}

The class name comes from the framework's routing convention, so the file lands
where the router will look for it. Placeholders must come last: `/album/{id}/edit`
Expand Down
2 changes: 2 additions & 0 deletions resources/infection.json5
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"Crest\\Generator\\Stub::render",
"Crest\\Project\\Config::psr4Map",
"Crest\\Command\\ServeCommand::envValue",
// Locator::project() found the composer.json first.
"Crest\\HandOff::missing",
// Same guarantee from further off: sources() only ever yields
// paths it has confirmed with is_file(), or glob() results.
"Crest\\Command\\Stub\\PublishCommand::handle",
Expand Down
10 changes: 5 additions & 5 deletions resources/stubs/adr/project-readme.stub
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ Then open http://localhost:8080/.

## Next steps

Use the crest in `vendor/` for these commands. It uses the autoloader and
the Phalcon of this project. On the host:
These commands use the crest in `vendor/`, with the autoloader and the
Phalcon of this project. On the host, a global crest passes them to it:

vendor/bin/crest make:action GET /hello
vendor/bin/crest route:list
crest make:action GET /hello
crest route:list

With docker:
Without a global crest, use `vendor/bin/crest`. With docker:

docker compose exec {{ service }} vendor/bin/crest make:action GET /hello
docker compose exec {{ service }} vendor/bin/crest route:list
6 changes: 6 additions & 0 deletions src/Commands.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
*/
final class Commands
{
/**
* The commands that run before a project exists. A global crest runs
* them itself, and passes the other commands to the crest of the project.
*/
public const HOST = ['down', 'install', 'new', 'up'];

/**
* Composer `extra` key packages use to contribute commands.
*/
Expand Down
183 changes: 183 additions & 0 deletions src/HandOff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

/**
* This file is part of the Phalcon Crest.
*
* (c) Phalcon Team <team@phalcon.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Crest;

use Crest\Console\Output;
use Crest\Process\Runner;
use Crest\Process\ShellRunner;
use Crest\Project\Locator;

use function array_slice;
use function file_get_contents;
use function in_array;
use function is_file;
use function json_decode;
use function realpath;
use function sprintf;
use function str_starts_with;
use function strlen;
use function substr;

use const PHP_BINARY;

/**
* Passes a project command from a global crest to the crest of the project.
*
* A global crest loads its own autoloader. The project commands need the
* autoloader, the Phalcon and the crest version of the project. Thus, in a
* project, a global crest runs vendor/bin/crest of the project with the same
* arguments, and returns its exit status.
*
* The host commands (Commands::HOST) and --version stay in the crest that the
* user typed.
*/
final class HandOff
{
/**
* Where composer puts the crest of a project that requires it.
*/
public const BINARY = 'vendor/bin/crest';

private readonly Runner $runner;

private readonly string $self;

/**
* @param string|null $self The root of the running crest package. Null
* for this package. A test gives another root.
*/
public function __construct(?Runner $runner = null, ?string $self = null)
{
$this->runner = $runner ?? new ShellRunner();
$this->self = $self ?? Paths::root();
}

/**
* @param list<string> $argv
*
* @return int|null The exit status of the crest of the project. Null when
* this crest runs the call.
*/
public function run(array $argv, Output $output): ?int
{
$tokens = array_slice($argv, 1);

if (false === $this->isProjectCall($tokens)) {
return null;
}

$start = realpath($this->directory($tokens));
$root = false === $start ? null : Locator::project($start);

if (null === $root) {
return null;
}

$binary = $root . '/' . self::BINARY;

if (false === is_file($binary)) {
return $this->missing($root, $output);
}

if (true === $this->isSelf($root)) {
return null;
}

return $this->runner->run([PHP_BINARY, $binary, ...$tokens]);
}

/**
* Where the walk up starts: the --directory value, or the working
* directory. The last value wins, as in the parser. After `--`, tokens
* are values, not options. An empty value reads as absent.
*
* @param list<string> $tokens
*/
private function directory(array $tokens): string
{
$directory = '';

foreach ($tokens as $index => $token) {
if ('--' === $token) {
break;
}

if (true === str_starts_with($token, '--directory=')) {
$directory = substr($token, strlen('--directory='));
}

if ('--directory' === $token) {
$directory = $tokens[$index + 1] ?? '';
}
}

return '' === $directory ? '.' : $directory;
}

/**
* The listing (no command, or an option first) and each command that is
* not a host command belong to the project. --version gives the version
* of the crest that the user typed.
*
* @param list<string> $tokens
*/
private function isProjectCall(array $tokens): bool
{
$first = $tokens[0] ?? null;

if (null === $first || true === str_starts_with($first, '-')) {
return false === in_array($first, ['--version', '-V'], true);
}

return false === in_array($first, Commands::HOST, true);
}

/**
* The crest of the project is this package. Without this check, the
* crest of the project passes the call to itself for ever.
*/
private function isSelf(string $root): bool
{
return realpath($root . '/vendor/' . Commands::PACKAGE) === realpath($this->self);
}

/**
* No vendor/bin/crest. A project that requires crest has no vendor/ yet:
* no crest can run a project command there, so this is an error. Other
* projects do not use crest: this crest runs the call, as before.
*/
private function missing(string $root, Output $output): ?int
{
/** @var array{require?: array<string, string>, require-dev?: array<string, string>}|null $composer */
$composer = json_decode((string) file_get_contents($root . '/composer.json'), true);

if (
false === isset($composer['require'][Commands::PACKAGE])
&& false === isset($composer['require-dev'][Commands::PACKAGE])
) {
return null;
}

$output->error(
sprintf(
"%s: %s has no %s; run 'crest install' or 'composer install' first",
Commands::NAME,
$root,
self::BINARY
)
);

return 1;
}
}
27 changes: 24 additions & 3 deletions src/Project/Locator.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,40 @@
use function is_file;

/**
* Finds the nearest crest.php by walking up from a starting directory, so
* crest works from anywhere inside a project.
* Finds a file by walking up from a starting directory, so crest works from
* anywhere inside a project.
*/
final class Locator
{
public const FILENAME = 'crest.php';

private const MANIFEST = 'composer.json';

/**
* The nearest crest.php.
*/
public static function locate(string $from): ?string
{
return self::nearest($from, self::FILENAME);
}

/**
* The project root: the nearest directory with a composer.json. The
* vendor/ directory of the project is next to that file.
*/
public static function project(string $from): ?string
{
$manifest = self::nearest($from, self::MANIFEST);

return null === $manifest ? null : dirname($manifest);
}

private static function nearest(string $from, string $name): ?string
{
$current = $from;

while (true) {
$candidate = $current . '/' . self::FILENAME;
$candidate = $current . '/' . $name;

if (true === is_file($candidate)) {
return $candidate;
Expand Down
Loading
Loading