diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ba4f547 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8be1427 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: [push] + +jobs: + phpunit: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest] + php-version: ['7.4'] + name: 'PHPUnit - PHP/${{ matrix.php-version }} - OS/${{ matrix.os }}' + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Setup PHP + uses: shivammathur/setup-php@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + coverage: xdebug + ini-values: xdebug.max_nesting_level=2048 + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Dependencies + run: composer install --no-progress + - name: PHPUnit + run: vendor/bin/phpunit --coverage-clover=coverage.clover + - uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + psalm: + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['7.4'] + name: 'Psalm - PHP/${{ matrix.php-version }}' + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Setup PHP + uses: shivammathur/setup-php@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl + - name: Get Composer Cache Directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Install Dependencies + run: composer install --no-progress + - name: Psalm + run: vendor/bin/psalm --shepherd diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb0a8e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.result.cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..05a12e9 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# CLI Framework + +[![codecov](https://codecov.io/gh/Innmind/CLIFramework/branch/develop/graph/badge.svg)](https://codecov.io/gh/Innmind/CLIFramework) +[![Build Status](https://github.com/Innmind/CLIFramework/workflows/CI/badge.svg)](https://github.com/Innmind/CLIFramework/actions?query=workflow%3ACI) +[![Type Coverage](https://shepherd.dev/github/Innmind/CLIFramework/coverage.svg)](https://shepherd.dev/github/Innmind/CLIFramework) + +Small library on top of [`innmind/cli`](https://github.com/innmind/cli) to automatically enable some features. + +## Installation + +```sh +composer require innmind/cli-framework +``` + +## Usage + +```php +use Innmind\CLI\{ + Environment, + Command, +}; +use Innmind\CLI\Framework\Application; +use Innmind\OperatingSystem\OperatingSystem; +use Innmind\Url\Path; + +new class extends Main { + protected function main(Environment $env, OperatingSystem $os): void + { + Application::of($env, $os) + ->configAt(Path::of('/path/to/config/directory/')) + ->commands(fn(Environment $env, OperatingSystem $os): array => [ + // a list of objects implementing Command + ]) + ->run(); + } +} +``` + +This simple example will try to locate a file named `.env` in the directory provided and will add the variables to the map returned by `$env->variables()` in the `commands` callable. + +By default it enables the usage of [`innmind/silent-cartographer`](https://github.com/innmind/silentcartographer), but can be disabled by calling `->disableSilentCartographer()`. + +When a `PROFILER` environment variable is declared it will enable [`innmind/debug`](https://github.com/innmind/debug), you can disable specific sections of the profiler by calling `->disableProfilerSection(...$sectionsClassNameToDisable)`. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..df623b9 --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "innmind/cli-framework", + "type": "library", + "description": "CLI framework", + "keywords": ["assistant"], + "homepage": "http://github.com/Innmind/CLIFramework", + "license": "MIT", + "authors": [ + { + "name": "Baptiste Langlade", + "email": "langlade.baptiste@gmail.com" + } + ], + "support": { + "issues": "http://github.com/Innmind/CLIFramework/issues" + }, + "require": { + "php": "~7.4", + "innmind/cli": "~2.0", + "symfony/dotenv": "^5.0", + "innmind/silent-cartographer": "^2.0", + "innmind/debug": "^2.0" + }, + "autoload": { + "psr-4": { + "Innmind\\CLI\\Framework\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\Innmind\\CLI\\Framework\\": "tests/" + } + }, + "require-dev": { + "phpunit/phpunit": "~8.0", + "vimeo/psalm": "^3.7" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..148afda --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + + ./tests + + + + + + . + + ./tests + ./vendor + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..617f63d --- /dev/null +++ b/psalm.xml @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..bb8f1ec --- /dev/null +++ b/src/Application.php @@ -0,0 +1,168 @@ + */ + private \Closure $commands; + /** @var \Closure(Environment, OperatingSystem): Environment */ + private \Closure $loadDotEnv; + /** @var \Closure(Environment, OperatingSystem): OperatingSystem */ + private \Closure $enableSilentCartographer; + /** @var list> */ + private array $disabledSections; + + /** + * @param callable(Environment, OperatingSystem): list $commands + * @param callable(Environment, OperatingSystem): Environment $loadDotEnv + * @param callable(Environment, OperatingSystem): OperatingSystem $enableSilentCartographer + * @param list> $disabledSections + */ + private function __construct( + Environment $env, + OperatingSystem $os, + callable $commands, + callable $loadDotEnv, + callable $enableSilentCartographer, + array $disabledSections + ) { + $this->env = $env; + $this->os = $os; + $this->commands = \Closure::fromCallable($commands); + $this->loadDotEnv = \Closure::fromCallable($loadDotEnv); + $this->enableSilentCartographer = \Closure::fromCallable($enableSilentCartographer); + $this->disabledSections = $disabledSections; + } + + public static function of(Environment $env, OperatingSystem $os): self + { + return new self( + $env, + $os, + static fn(): array => [], + static fn(Environment $env): Environment => $env, + static fn(Environment $env, OperatingSystem $os): OperatingSystem => cartographer($os)['cli']( + Url::of('/')->withPath( + $env->workingDirectory(), + ), + ), + [], + ); + } + + /** + * @param callable(Environment, OperatingSystem): list $commands + */ + public function commands(callable $commands): self + { + return new self( + $this->env, + $this->os, + fn(Environment $env, OperatingSystem $os): array => \array_merge( + ($this->commands)($env, $os), + $commands($env, $os), + ), + $this->loadDotEnv, + $this->enableSilentCartographer, + [], + ); + } + + public function configAt(Path $path): self + { + return new self( + $this->env, + $this->os, + $this->commands, + static fn(Environment $env, OperatingSystem $os): Environment => new DotEnvAware( + $env, + $os->filesystem(), + $path, + ), + $this->enableSilentCartographer, + [], + ); + } + + public function disableSilentCartographer(): self + { + return new self( + $this->env, + $this->os, + $this->commands, + $this->loadDotEnv, + static fn(Environment $env, OperatingSystem $os): OperatingSystem => $os, + [], + ); + } + + /** + * @param list> $sections + */ + public function disableProfilerSection(string ...$sections): self + { + return new self( + $this->env, + $this->os, + $this->commands, + $this->loadDotEnv, + $this->enableSilentCartographer, + \array_merge( + $this->disabledSections, + $sections, + ), + ); + } + + public function run(): void + { + $os = ($this->enableSilentCartographer)($this->env, $this->os); + $env = ($this->loadDotEnv)($this->env, $os); + $debugEnabled = $env->variables()->contains('PROFILER'); + $wrapCommands = static fn(Command ...$commands): array => $commands; + + if ($debugEnabled) { + $debug = debug( + $os, + Url::of($env->variables()->get('PROFILER')), + $env->variables(), + null, + Set::strings(...$this->disabledSections), + ); + $os = $debug['os'](); + $wrapCommands = static fn(Command ...$commands): array => unwrap( + $debug['cli'](...$commands), + ); + } + + $commands = ($this->commands)($env, $os); + $commands = \count($commands) === 0 ? [new HelloWorld] : $commands; + + if ($debugEnabled) { + $commands = $wrapCommands(...$commands); + } + + $run = new Commands(...$commands); + $run($env); + } +} diff --git a/src/DotEnvAware.php b/src/DotEnvAware.php new file mode 100644 index 0000000..efa832b --- /dev/null +++ b/src/DotEnvAware.php @@ -0,0 +1,102 @@ +env = $env; + $this->filesystem = $filesystem; + $this->config = $config; + } + + public function interactive(): bool + { + return $this->env->interactive(); + } + + public function input(): Readable + { + return $this->env->input(); + } + + public function output(): Writable + { + return $this->env->output(); + } + + public function error(): Writable + { + return $this->env->error(); + } + + public function arguments(): Sequence + { + return $this->env->arguments(); + } + + public function variables(): Map + { + $variables = $this->env->variables(); + + if (!$this->filesystem->contains($this->config)) { + return $variables; + } + + $config = $this->filesystem->mount($this->config); + + if (!$config->contains(new Name('.env'))) { + return $variables; + } + + /** @var array */ + $dot = (new Dotenv)->parse($config->get(new Name('.env'))->content()->toString()); + + foreach ($dot as $key => $value) { + $variables = ($variables)($key, $value); + } + + return $variables; + } + + public function exit(int $code): void + { + $this->env->exit($code); + } + + public function exitCode(): ExitCode + { + return $this->env->exitCode(); + } + + public function workingDirectory(): Path + { + return $this->env->workingDirectory(); + } +} diff --git a/src/HelloWorld.php b/src/HelloWorld.php new file mode 100644 index 0000000..72cc890 --- /dev/null +++ b/src/HelloWorld.php @@ -0,0 +1,25 @@ +output()->write(Str::of("Hello world\n")); + } + + public function toString(): string + { + return 'hello-world'; + } +} diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php new file mode 100644 index 0000000..57f2f08 --- /dev/null +++ b/tests/ApplicationTest.php @@ -0,0 +1,416 @@ +createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $env + ->method('output') + ->willReturn($output = $this->createMock(Writable::class)); + $output + ->expects($this->once()) + ->method('write') + ->with(Str::of("Hello world\n")); + $os = $this->createMock(OperatingSystem::class); + + $app = Application::of($env, $os); + $app = $app->disableSilentCartographer(); + + $this->assertNull($app->run()); + } + + public function testHelloWorldCommandDisappearWhenOneCommandProvided() + { + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + $env->output()->write(Str::of('foo')); + } + + public function toString(): string + { + return 'foo'; + } + }; + + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $env + ->method('output') + ->willReturn($output = $this->createMock(Writable::class)); + $output + ->expects($this->once()) + ->method('write') + ->with($this->callback(function($text) { + return !$text->contains('Hello world') && + $text->contains('foo'); + })); + $os = $this->createMock(OperatingSystem::class); + + $app = Application::of($env, $os); + $app = $app->disableSilentCartographer(); + $app2 = $app->commands(fn() => [$command]); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app, $app2); + $this->assertNull($app2->run()); + } + + public function testListCommandsWhenMoreThanOneProvided() + { + $foo = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + } + + public function toString(): string + { + return 'foo'; + } + }; + $bar = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + } + + public function toString(): string + { + return 'bar'; + } + }; + + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $env + ->method('output') + ->willReturn($output = $this->createMock(Writable::class)); + $output + ->method('write') + ->with($this->callback(function($text) { + return !$text->contains('Hello world') && + ($text->contains('foo') || $text->contains('bar')); + })); + $os = $this->createMock(OperatingSystem::class); + + $app = Application::of($env, $os); + $app = $app->disableSilentCartographer(); + $app2 = $app->commands(fn() => [$foo, $bar]); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app, $app2); + $this->assertNull($app2->run()); + } + + public function testNoErrorWhenSpecifyingUnknownConfigDirectory() + { + $configPath = Path::of('/somewhere/'); + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $os = $this->createMock(OperatingSystem::class); + $os + ->method('filesystem') + ->willReturn($filesystem = $this->createMock(Filesystem::class)); + $filesystem + ->method('contains') + ->with($configPath) + ->willReturn(false); + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + $env->output()->write(Str::of('foo')); + } + + public function toString(): string + { + return 'foo'; + } + }; + + $app = Application::of($env, $os); + $app2 = $app + ->disableSilentCartographer() + ->commands(fn() => [$command]) + ->configAt($configPath); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app, $app2); + $this->assertNull($app2->run()); + } + + public function testNoErrorWhenSpecifyingConfigDirectoryWithoutADotEnvFile() + { + $configPath = Path::of('/somewhere/'); + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $os = $this->createMock(OperatingSystem::class); + $os + ->method('filesystem') + ->willReturn($filesystem = $this->createMock(Filesystem::class)); + $filesystem + ->method('contains') + ->with($configPath) + ->willReturn(true); + $filesystem + ->method('mount') + ->with($configPath) + ->willReturn(new InMemory); + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + $env->output()->write(Str::of('foo')); + } + + public function toString(): string + { + return 'foo'; + } + }; + + $app = Application::of($env, $os); + $app2 = $app + ->disableSilentCartographer() + ->commands(fn() => [$command]) + ->configAt($configPath); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app, $app2); + $this->assertNull($app2->run()); + } + + public function testLoadDotEnv() + { + $configPath = Path::of('/somewhere/'); + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn( + Map::of('string', 'string') + ('FOO', 'bar') + ('BAZ', 'bar') + ); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $os = $this->createMock(OperatingSystem::class); + $os + ->method('filesystem') + ->willReturn($filesystem = $this->createMock(Filesystem::class)); + $filesystem + ->method('contains') + ->with($configPath) + ->willReturn(true); + $filesystem + ->method('mount') + ->with($configPath) + ->willReturn($config = new InMemory); + $config->add(File::named( + '.env', + Stream::ofContent("FOO=baz\nBAR=foo"), + )); + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + if ($env->variables()->get('FOO') !== 'baz') { + throw new \Exception('Dot env do not override real variables'); + } + + if (!$env->variables()->contains('BAR')) { + throw new \Exception('Dot env not loaded'); + } + + if (!$env->variables()->contains('BAZ')) { + throw new \Exception('Real variables lost'); + } + } + + public function toString(): string + { + return 'foo'; + } + }; + + $app = Application::of($env, $os); + $app2 = $app + ->disableSilentCartographer() + ->commands(function($env) use ($command) { + $this->assertSame('baz', $env->variables()->get('FOO')); + + if (!$env->variables()->contains('BAR')) { + $this->fail('Dot env not loaded'); + } + + if (!$env->variables()->contains('BAZ')) { + $this->fail('Real variables lost'); + } + + return [$command]; + }) + ->configAt($configPath); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app, $app2); + $this->assertNull($app2->run()); + } + + public function testSilentCartographerEnabledByDefaultWithWorkingDirectoryAsRoomLocation() + { + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $env + ->method('workingDirectory') + ->willReturn(Path::of('/working/directory/')); + $env + ->method('output') + ->willReturn($output = $this->createMock(Writable::class)); + $output + ->expects($this->once()) + ->method('write') + ->with(Str::of("foo")); + $os = $this->createMock(OperatingSystem::class); + $os + ->method('process') + ->willReturn($process = $this->createMock(CurrentProcess::class)); + $process + ->method('id') + ->willReturn(new Pid(42)); + $os + ->method('status') + ->willReturn($status = $this->createMock(Server::class)); + $status + ->method('tmp') + ->willReturn(Path::of('/tmp/')); + + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + $env->output()->write(Str::of('foo')); + } + + public function toString(): string + { + return 'foo'; + } + }; + + $app = Application::of($env, $os); + $app = $app->commands(function($env, $os) use ($command) { + if (!$os instanceof SilentCartographer) { + $this->fail('Silent cartographer not enabled'); + } + + return [$command]; + }); + + $this->assertNull($app->run()); + } + + public function testAllowToDisableProfilerSections() + { + $env = $this->createMock(Environment::class); + $env + ->method('arguments') + ->willReturn(Sequence::strings()); + $env + ->method('variables') + ->willReturn(Map::of('string', 'string')); + $env + ->method('output') + ->willReturn($output = $this->createMock(Writable::class)); + $output + ->expects($this->once()) + ->method('write') + ->with(Str::of("foo")); + $os = $this->createMock(OperatingSystem::class); + + $command = new class implements Command { + public function __invoke(Environment $env, Arguments $arguments, Options $options): void + { + $env->output()->write(Str::of('foo')); + } + + public function toString(): string + { + return 'foo'; + } + }; + + $app = Application::of($env, $os); + $app2 = $app + ->disableSilentCartographer() + ->disableProfilerSection(CaptureAppGraph::class) + ->commands(fn($env, $os) => [$command]); + + $this->assertInstanceOf(Application::class, $app2); + $this->assertNotSame($app2, $app); + $this->assertNull($app2->run()); + } +}