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());
+ }
+}