From 9ea97f18e171cd2b31103095cc3596e3945249e4 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 19:33:26 +0100 Subject: [PATCH 1/7] Implement storage defaulting to the old one and required when using as PHAR --- src/Commands/Check.php | 16 ++++++++++++---- src/Commands/Issue.php | 20 ++++++++++++++------ src/Commands/Revoke.php | 18 ++++++++++++++---- src/Commands/Setup.php | 12 ++++++++++-- src/functions.php | 13 +++++++++++++ test/FunctionsTest.php | 11 +++++++++++ 6 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/Commands/Check.php b/src/Commands/Check.php index d65f695..0c910ef 100644 --- a/src/Commands/Check.php +++ b/src/Commands/Check.php @@ -26,7 +26,7 @@ class Check implements Command { $server = \Kelunik\AcmeClient\resolveServer($args->get("server")); $server = \Kelunik\AcmeClient\serverToKeyname($server); - $path = dirname(dirname(__DIR__)) . "/data/certs/" . $server; + $path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $server; $certificateStore = new CertificateStore($path); $pem = (yield $certificateStore->get($args->get("name"))); @@ -35,20 +35,22 @@ class Check implements Command { $this->climate->info("Certificate is valid until " . date("d.m.Y", $cert->getValidTo())); if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) { - exit(0); + return 0; } $this->climate->comment("Certificate is going to expire within the specified " . $args->get("ttl") . " days."); - exit(1); + return 1; } public static function getDefinition() { + $isPhar = \Kelunik\AcmeClient\isPhar(); + return [ "server" => [ "prefix" => "s", "longPrefix" => "server", - "description" => "", + "description" => "ACME server to use for registration and issuance of certificates.", "required" => true, ], "name" => [ @@ -62,6 +64,12 @@ class Check implements Command { "defaultValue" => 30, "castTo" => "int", ], + "storage" => [ + "longPrefix" => "storage", + "description" => "Storage directory for account keys and certificates.", + "required" => $isPhar, + "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") + ] ]; } } \ No newline at end of file diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index ae134f2..cf1e30e 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -63,7 +63,7 @@ class Issue implements Command { ); } - $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); + $keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage"))); $server = \Kelunik\AcmeClient\resolveServer($args->get("server")); $keyFile = \Kelunik\AcmeClient\serverToKeyname($server); @@ -71,9 +71,7 @@ class Issue implements Command { try { $keyPair = (yield $keyStore->get("accounts/{$keyFile}.pem")); } catch (KeyStoreException $e) { - $this->climate->error("Account key not found, did you run 'bin/acme setup'?"); - - exit(1); + throw new AcmeException("Account key not found, did you run 'bin/acme setup'?", 0, $e); } $acme = new AcmeService(new AcmeClient($server, $keyPair)); @@ -109,11 +107,13 @@ class Issue implements Command { $location = (yield $acme->requestCertificate($keyPair, $domains)); $certificates = (yield $acme->pollForCertificate($location)); - $path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile; + $path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile; $certificateStore = new CertificateStore($path); yield $certificateStore->put($certificates); $this->climate->info("Successfully issued certificate, see {$path}/" . reset($domains)); + + return 0; } private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) { @@ -196,11 +196,13 @@ class Issue implements Command { } public static function getDefinition() { + $isPhar = \Kelunik\AcmeClient\isPhar(); + return [ "server" => [ "prefix" => "s", "longPrefix" => "server", - "description" => "Server to use for issuance, see also 'bin/acme setup'.", + "description" => "ACME server to use for registration and issuance of certificates.", "required" => true, ], "domains" => [ @@ -226,6 +228,12 @@ class Issue implements Command { "defaultValue" => 2048, "castTo" => "int", ], + "storage" => [ + "longPrefix" => "storage", + "description" => "Storage directory for account keys and certificates.", + "required" => $isPhar, + "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") + ] ]; } } \ No newline at end of file diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php index b537beb..602f47f 100644 --- a/src/Commands/Revoke.php +++ b/src/Commands/Revoke.php @@ -23,7 +23,7 @@ class Revoke implements Command { } private function doExecute(Manager $args) { - $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); + $keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage"))); $server = \Kelunik\AcmeClient\resolveServer($args->get("server")); $keyFile = \Kelunik\AcmeClient\serverToKeyname($server); @@ -33,7 +33,7 @@ class Revoke implements Command { $this->climate->info("Revoking certificate ..."); - $path = dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem"; + $path = \Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/certs/" . $keyFile . "/" . $args->get("name") . "/cert.pem"; try { $pem = (yield \Amp\File\get($path)); @@ -50,15 +50,19 @@ class Revoke implements Command { yield $acme->revokeCertificate($pem); $this->climate->info("Certificate has been revoked."); - yield (new CertificateStore(dirname(dirname(__DIR__)) . "/data/certs/" . $keyFile))->delete($args->get("name")); + yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")). "/certs/" . $keyFile))->delete($args->get("name")); + + return 0; } public static function getDefinition() { + $isPhar = \Kelunik\AcmeClient\isPhar(); + return [ "server" => [ "prefix" => "s", "longPrefix" => "server", - "description" => "", + "description" => "ACME server to use for registration and issuance of certificates.", "required" => true, ], "name" => [ @@ -66,6 +70,12 @@ class Revoke implements Command { "description" => "Common name of the certificate to be revoked.", "required" => true, ], + "storage" => [ + "longPrefix" => "storage", + "description" => "Storage directory for account keys and certificates.", + "required" => $isPhar, + "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") + ] ]; } } \ No newline at end of file diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index 843c737..e9efc27 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -36,7 +36,7 @@ class Setup implements Command { $path = "accounts/{$keyFile}.pem"; $bits = 4096; - $keyStore = new KeyStore(dirname(dirname(__DIR__)) . "/data"); + $keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/data"); try { $keyPair = (yield $keyStore->get($path)); @@ -78,6 +78,8 @@ class Setup implements Command { } public static function getDefinition() { + $isPhar = \Kelunik\AcmeClient\isPhar(); + return [ "server" => [ "prefix" => "s", @@ -87,9 +89,15 @@ class Setup implements Command { ], "email" => [ "longPrefix" => "email", - "description" => "Email for important issues, will be sent to the ACME server.", + "description" => "E-mail for important issues, will be sent to the ACME server.", "required" => true, ], + "storage" => [ + "longPrefix" => "storage", + "description" => "Storage directory for account keys and certificates.", + "required" => $isPhar, + "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") + ] ]; } } \ No newline at end of file diff --git a/src/functions.php b/src/functions.php index 4f566ce..9592d0b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,6 +2,7 @@ namespace Kelunik\AcmeClient; +use Phar; use Webmozart\Assert\Assert; function suggestCommand($badCommand, array $commands, $suggestThreshold = 70) { @@ -56,4 +57,16 @@ function serverToKeyname($server) { $keyFile = preg_replace("@\\.+@", ".", $keyFile); return $keyFile; +} + +function isPhar() { + if (!class_exists("Phar")) { + return false; + } + + return Phar::running(true) !== ""; +} + +function normalizePath($path) { + return rtrim(str_replace("\\", "/", $path), "/"); } \ No newline at end of file diff --git a/test/FunctionsTest.php b/test/FunctionsTest.php index f48c771..4013eea 100644 --- a/test/FunctionsTest.php +++ b/test/FunctionsTest.php @@ -15,4 +15,15 @@ class FunctionsTest extends \PHPUnit_Framework_TestCase { $this->assertSame("acme", suggestCommand("acme!", ["acme"])); $this->assertSame("", suggestCommand("issue", ["acme"])); } + + public function testIsPhar() { + $this->assertFalse(isPhar()); + } + + public function testNormalizePath() { + $this->assertSame("/etc/foobar", normalizePath("/etc/foobar")); + $this->assertSame("/etc/foobar", normalizePath("/etc/foobar/")); + $this->assertSame("/etc/foobar", normalizePath("/etc/foobar/")); + $this->assertSame("C:/etc/foobar", normalizePath("C:\\etc\\foobar\\")); + } } \ No newline at end of file From c36ced9b7cae83adfc47b87a18a1d49420af15d9 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 20:02:25 +0100 Subject: [PATCH 2/7] Ensure consistent server and storage arguments, fix storage path for setup --- composer.json | 4 ++-- src/Commands/Check.php | 16 ++-------------- src/Commands/Issue.php | 16 ++-------------- src/Commands/Revoke.php | 16 ++-------------- src/Commands/Setup.php | 18 ++++-------------- src/functions.php | 30 ++++++++++++++++++++++++++++++ 6 files changed, 42 insertions(+), 58 deletions(-) diff --git a/composer.json b/composer.json index efe91a5..3e6a774 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "require-dev": { "phpunit/phpunit": "^5", "fabpot/php-cs-fixer": "^1.9", - "macfja/phar-builder": "^0.2.3" + "macfja/phar-builder": "dev-master#bf48160bd6bdf702557ff213e6837710c1dbf572" }, "license": "MIT", "authors": [ @@ -47,7 +47,7 @@ "compression": "GZip", "name": "acme.phar", "output-dir": "build", - "include": ["bin", "src", "vendor"], + "include": ["src"], "entry-point": "bin/acme" } } diff --git a/src/Commands/Check.php b/src/Commands/Check.php index 0c910ef..61c122c 100644 --- a/src/Commands/Check.php +++ b/src/Commands/Check.php @@ -44,15 +44,9 @@ class Check implements Command { } public static function getDefinition() { - $isPhar = \Kelunik\AcmeClient\isPhar(); - return [ - "server" => [ - "prefix" => "s", - "longPrefix" => "server", - "description" => "ACME server to use for registration and issuance of certificates.", - "required" => true, - ], + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "name" => [ "longPrefix" => "name", "description" => "Common name of the certificate to check.", @@ -64,12 +58,6 @@ class Check implements Command { "defaultValue" => 30, "castTo" => "int", ], - "storage" => [ - "longPrefix" => "storage", - "description" => "Storage directory for account keys and certificates.", - "required" => $isPhar, - "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") - ] ]; } } \ No newline at end of file diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index cf1e30e..1d6c3a5 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -196,15 +196,9 @@ class Issue implements Command { } public static function getDefinition() { - $isPhar = \Kelunik\AcmeClient\isPhar(); - return [ - "server" => [ - "prefix" => "s", - "longPrefix" => "server", - "description" => "ACME server to use for registration and issuance of certificates.", - "required" => true, - ], + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "domains" => [ "prefix" => "d", "longPrefix" => "domains", @@ -228,12 +222,6 @@ class Issue implements Command { "defaultValue" => 2048, "castTo" => "int", ], - "storage" => [ - "longPrefix" => "storage", - "description" => "Storage directory for account keys and certificates.", - "required" => $isPhar, - "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") - ] ]; } } \ No newline at end of file diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php index 602f47f..c176513 100644 --- a/src/Commands/Revoke.php +++ b/src/Commands/Revoke.php @@ -56,26 +56,14 @@ class Revoke implements Command { } public static function getDefinition() { - $isPhar = \Kelunik\AcmeClient\isPhar(); - return [ - "server" => [ - "prefix" => "s", - "longPrefix" => "server", - "description" => "ACME server to use for registration and issuance of certificates.", - "required" => true, - ], + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "name" => [ "longPrefix" => "name", "description" => "Common name of the certificate to be revoked.", "required" => true, ], - "storage" => [ - "longPrefix" => "storage", - "description" => "Storage directory for account keys and certificates.", - "required" => $isPhar, - "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") - ] ]; } } \ No newline at end of file diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index e9efc27..40df5ec 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -36,7 +36,7 @@ class Setup implements Command { $path = "accounts/{$keyFile}.pem"; $bits = 4096; - $keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")) . "/data"); + $keyStore = new KeyStore(\Kelunik\AcmeClient\normalizePath($args->get("storage"))); try { $keyPair = (yield $keyStore->get($path)); @@ -78,26 +78,16 @@ class Setup implements Command { } public static function getDefinition() { - $isPhar = \Kelunik\AcmeClient\isPhar(); + return [ - "server" => [ - "prefix" => "s", - "longPrefix" => "server", - "description" => "ACME server to use for registration and issuance of certificates.", - "required" => true, - ], + "server" => \Kelunik\AcmeClient\getArgumentDescription("server"), + "storage" => \Kelunik\AcmeClient\getArgumentDescription("storage"), "email" => [ "longPrefix" => "email", "description" => "E-mail for important issues, will be sent to the ACME server.", "required" => true, ], - "storage" => [ - "longPrefix" => "storage", - "description" => "Storage directory for account keys and certificates.", - "required" => $isPhar, - "defaultValue" => $isPhar ? null : (__DIR__ . "/../../data") - ] ]; } } \ No newline at end of file diff --git a/src/functions.php b/src/functions.php index 9592d0b..812ada5 100644 --- a/src/functions.php +++ b/src/functions.php @@ -69,4 +69,34 @@ function isPhar() { function normalizePath($path) { return rtrim(str_replace("\\", "/", $path), "/"); +} + +function getArgumentDescription($argument) { + $isPhar = \Kelunik\AcmeClient\isPhar(); + + switch ($argument) { + case "server": + return [ + "prefix" => "s", + "longPrefix" => "server", + "description" => "ACME server to use for registration and issuance of certificates.", + "required" => true, + ]; + + case "storage": + $argument = [ + "longPrefix" => "storage", + "description" => "Storage directory for account keys and certificates.", + "required" => $isPhar, + ]; + + if (!$isPhar) { + $argument["defaultValue"] = dirname(__DIR__) . "/data"; + } + + return $argument; + + default: + throw new \InvalidArgumentException("Unknown argument: " . $argument); + } } \ No newline at end of file From e8f35811fbedbf8aedd23180dfa3e7f3394f1f05 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 21:32:10 +0100 Subject: [PATCH 3/7] Separate paths on Windows with ; instead of : --- src/Commands/Issue.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index 1d6c3a5..33c507e 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -44,10 +44,10 @@ class Issue implements Command { } } - $domains = array_map("trim", explode(":", str_replace(",", ":", $args->get("domains")))); + $domains = array_map("trim", explode(":", str_replace([",", ";"], ":", $args->get("domains")))); yield \Amp\resolve($this->checkDnsRecords($domains)); - $docRoots = explode(":", str_replace("\\", "/", $args->get("path"))); + $docRoots = explode(PATH_SEPARATOR, str_replace("\\", "/", $args->get("path"))); $docRoots = array_map(function ($root) { return rtrim($root, "/"); }, $docRoots); @@ -202,13 +202,13 @@ class Issue implements Command { "domains" => [ "prefix" => "d", "longPrefix" => "domains", - "description" => "Colon separated list of domains to request a certificate for.", + "description" => "Colon / Semicolon / Comma separated list of domains to request a certificate for.", "required" => true, ], "path" => [ "prefix" => "p", "longPrefix" => "path", - "description" => "Colon separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.", + "description" => "Colon (Unix) / Semicolon (Windows) separated list of paths to the document roots. The last one will be used for all remaining ones if fewer than the amount of domains is given.", "required" => true, ], "user" => [ From 6173b779e12e0f465405d628726e1d44b4d24ebb Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 21:32:29 +0100 Subject: [PATCH 4/7] Fix phar creation --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 3e6a774..52bbc59 100644 --- a/composer.json +++ b/composer.json @@ -45,9 +45,9 @@ "extra": { "phar-builder": { "compression": "GZip", - "name": "acme.phar", + "name": "acme-client.phar", "output-dir": "build", - "include": ["src"], + "include": ["src", "vendor/kelunik/acme/res"], "entry-point": "bin/acme" } } From 93c94d1a9bbaa79e02d62e98e1028a2f843bb1ce Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 21:58:26 +0100 Subject: [PATCH 5/7] Fix exit codes --- bin/acme | 8 +++++++- src/Commands/Setup.php | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bin/acme b/bin/acme index 3424a11..7e21837 100755 --- a/bin/acme +++ b/bin/acme @@ -125,7 +125,13 @@ Amp\run(function () use ($command, $climate) { }; try { - yield $command->execute($climate->arguments); + $exitCode = (yield $command->execute($climate->arguments)); + + if ($exitCode === null) { + $logger->warning("Invalid exit code: null, falling back to 0. Please consider reporting this as bug."); + } + + exit($exitCode); } catch (Throwable $e) { $handler($e); } catch (Exception $e) { diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index 40df5ec..da55ff7 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -57,6 +57,8 @@ class Setup implements Command { /** @var Registration $registration */ $registration = (yield $acme->register($email)); $this->climate->whisper("Registration successful with the following contact information: " . implode(", ", $registration->getContact())); + + return 0; } private function checkEmail($email) { From 8549ff9e464046302493e4618ab905c2c7569477 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 22:01:47 +0100 Subject: [PATCH 6/7] Fix documentation bug: Missing server on revocation --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0a5d3c2..a30814a 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ bin/acme issue -s letsencrypt -d example.com:www.example.com -p /var/www/example To revoke a certificate, you need a valid account key currently, just like for issuance. ``` -bin/acme revoke --name example.com +bin/acme revoke --name example.com -s letsencrypt ``` For renewal, there's the `bin/acme check` subcommand. From 1c4a2387e9a77e565a0a9124c54e81b5d3dd7e50 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Wed, 23 Mar 2016 22:05:24 +0100 Subject: [PATCH 7/7] Fix exit codes on PHP 5 --- src/Commands/Check.php | 6 ++++-- src/Commands/Issue.php | 3 ++- src/Commands/Revoke.php | 3 ++- src/Commands/Setup.php | 3 ++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Commands/Check.php b/src/Commands/Check.php index 61c122c..cc5064a 100644 --- a/src/Commands/Check.php +++ b/src/Commands/Check.php @@ -2,6 +2,7 @@ namespace Kelunik\AcmeClient\Commands; +use Amp\CoroutineResult; use Kelunik\AcmeClient\Stores\CertificateStore; use Kelunik\Certificate\Certificate; use League\CLImate\Argument\Manager; @@ -35,12 +36,13 @@ class Check implements Command { $this->climate->info("Certificate is valid until " . date("d.m.Y", $cert->getValidTo())); if ($cert->getValidTo() > time() + $args->get("ttl") * 24 * 60 * 60) { - return 0; + yield new CoroutineResult(0); + return; } $this->climate->comment("Certificate is going to expire within the specified " . $args->get("ttl") . " days."); - return 1; + yield new CoroutineResult(1); } public static function getDefinition() { diff --git a/src/Commands/Issue.php b/src/Commands/Issue.php index 33c507e..e87030c 100644 --- a/src/Commands/Issue.php +++ b/src/Commands/Issue.php @@ -2,6 +2,7 @@ namespace Kelunik\AcmeClient\Commands; +use Amp\CoroutineResult; use Amp\Dns\Record; use Exception; use Kelunik\Acme\AcmeClient; @@ -113,7 +114,7 @@ class Issue implements Command { $this->climate->info("Successfully issued certificate, see {$path}/" . reset($domains)); - return 0; + yield new CoroutineResult(0); } private function solveChallenge(AcmeService $acme, KeyPair $keyPair, $domain, $path) { diff --git a/src/Commands/Revoke.php b/src/Commands/Revoke.php index c176513..6b4d829 100644 --- a/src/Commands/Revoke.php +++ b/src/Commands/Revoke.php @@ -2,6 +2,7 @@ namespace Kelunik\AcmeClient\Commands; +use Amp\CoroutineResult; use Amp\File\FilesystemException; use Kelunik\Acme\AcmeClient; use Kelunik\Acme\AcmeService; @@ -52,7 +53,7 @@ class Revoke implements Command { yield (new CertificateStore(\Kelunik\AcmeClient\normalizePath($args->get("storage")). "/certs/" . $keyFile))->delete($args->get("name")); - return 0; + yield new CoroutineResult(0); } public static function getDefinition() { diff --git a/src/Commands/Setup.php b/src/Commands/Setup.php index da55ff7..0b1c14d 100644 --- a/src/Commands/Setup.php +++ b/src/Commands/Setup.php @@ -2,6 +2,7 @@ namespace Kelunik\AcmeClient\Commands; +use Amp\CoroutineResult; use Amp\Dns\Record; use Amp\Dns\ResolutionException; use InvalidArgumentException; @@ -58,7 +59,7 @@ class Setup implements Command { $registration = (yield $acme->register($email)); $this->climate->whisper("Registration successful with the following contact information: " . implode(", ", $registration->getContact())); - return 0; + yield new CoroutineResult(0); } private function checkEmail($email) {