diff --git a/README.md b/README.md index 17c416ae..8aa71290 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ Acquia CLI is not a local development environment. If you are looking for an int ## Installation and usage +### Quick start + +Go from nothing to a working local development environment with one command: + +```shell +curl -fsSL https://raw.githubusercontent.com/acquia/cli/main/install.sh | sh +``` + +This installs the latest Acquia CLI (a self-contained binary on macOS Apple Silicon and Linux x86_64 — no PHP required) and starts `acli dev:init`, which authenticates you with the Cloud Platform, clones one of your applications, provisions a local [ddev](https://ddev.com) stack, and imports your database and files. Already have acli installed? Just run `acli dev:init`. See `acli help dev:init` for details, including non-interactive usage for CI. + Install instructions and official documentation are available at https://docs.acquia.com/acquia-cli/install/ ### Shell completion diff --git a/composer.lock b/composer.lock index 44dd6942..cedc156e 100644 --- a/composer.lock +++ b/composer.lock @@ -13328,16 +13328,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.5", + "version": "3.13.6", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/4c378e1a528ea066890fc2397cbdd2f94eb2fc91", + "reference": "4c378e1a528ea066890fc2397cbdd2f94eb2fc91", "shasum": "" }, "require": { @@ -13403,7 +13403,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-04T16:30:35+00:00" + "time": "2026-08-06T00:17:32+00:00" }, { "name": "staabm/side-effects-detector", diff --git a/infection.json5 b/infection.json5 index d557b029..6da96d28 100644 --- a/infection.json5 +++ b/infection.json5 @@ -17,7 +17,14 @@ "\\$this->logger.*", // Cache TTLs only affect expiry timing, which is not observable in a // unit test without manipulating the clock. - ".*->expiresAfter\\(.*" + ".*->expiresAfter\\(.*", + // Console output and progress-checklist calls are interface text, + // not observable behavior — same class of noise as logger calls. + "\\$this->io->writeln.*", + "\\$this->io->success.*", + "\\$this->io->warning.*", + "\\$this->io->note.*", + "\\$this->checklist->.*" ] }, "timeout": 30, diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..85de5f57 --- /dev/null +++ b/install.sh @@ -0,0 +1,108 @@ +#!/bin/sh +# +# Acquia CLI installer. +# +# curl -fsSL https://raw.githubusercontent.com/acquia/cli/main/install.sh | sh +# +# Installs the latest release of Acquia CLI and then starts `acli dev:init`, +# which walks you through creating a complete local development environment. +# +# On macOS (Apple Silicon) and Linux (x86_64) this installs a self-contained +# native binary: PHP is NOT required. On other platforms it falls back to +# acli.phar, which requires PHP 8.2+. +# +# The script is deliberately small and readable — please do inspect it. +# Environment variables: +# ACLI_INSTALL_DIR Install directory (default: ~/.local/bin) +# ACLI_INSTALL_NO_SETUP Set to 1 to skip running `acli dev:init` after install. +# ACLI_INSTALL_BASE_URL Alternative download location (e.g. a PR build). + +set -eu + +REPO="acquia/cli" +INSTALL_DIR="${ACLI_INSTALL_DIR:-"$HOME/.local/bin"}" +BASE_URL="${ACLI_INSTALL_BASE_URL:-"https://github.com/$REPO/releases/latest/download"}" + +say() { printf '%s\n' "$*"; } +fail() { printf 'Error: %s\n' "$*" >&2; exit 1; } + +command -v curl >/dev/null 2>&1 || fail "curl is required. Install it and re-run this script." + +# Pick the right release asset for this machine. +OS="$(uname -s)" +ARCH="$(uname -m)" +ASSET="" +case "$OS-$ARCH" in + Darwin-arm64) ASSET="native-acli-macos-aarch64.tar.gz" ;; + Linux-x86_64) ASSET="native-acli-linux-x86_64.tar.gz" ;; +esac + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/acli-install.XXXXXXXX")" +trap 'rm -rf "$TMP_DIR"' EXIT + +# Verify a downloaded file against the .sha256 file published with the +# release. Older releases predate the checksum files; warn in that case. +verify_checksum() { + file="$1" + if ! curl -fsSL "$BASE_URL/$(basename "$file").sha256" -o "$file.sha256" 2>/dev/null; then + say "Warning: this release publishes no checksum for $(basename "$file"); skipping verification." + return 0 + fi + expected="$(cut -d' ' -f1 <"$file.sha256")" + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$file" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$file" | cut -d' ' -f1)" + fi + [ "$expected" = "$actual" ] || fail "Checksum mismatch for $(basename "$file"). Aborting." +} + +if [ -n "$ASSET" ]; then + say "Downloading Acquia CLI (native build, no PHP required) ..." + curl -fsSL "$BASE_URL/$ASSET" -o "$TMP_DIR/$ASSET" + verify_checksum "$TMP_DIR/$ASSET" + tar -xzf "$TMP_DIR/$ASSET" -C "$TMP_DIR" + ACLI_BIN="$TMP_DIR/acli" +else + # No native build for this platform: fall back to the phar, which needs PHP. + if ! command -v php >/dev/null 2>&1; then + fail "No native Acquia CLI build exists for $OS/$ARCH and PHP was not found. +Install PHP 8.2+ first (macOS: brew install php, Debian/Ubuntu: sudo apt install php-cli), then re-run this script." + fi + php -r 'exit(version_compare(PHP_VERSION, "8.2.0", ">=") ? 0 : 1);' \ + || fail "Acquia CLI requires PHP 8.2+; you have $(php -r 'echo PHP_VERSION;'). Upgrade PHP and re-run this script." + say "Downloading Acquia CLI (acli.phar) ..." + curl -fsSL "$BASE_URL/acli.phar" -o "$TMP_DIR/acli" + verify_checksum "$TMP_DIR/acli" + ACLI_BIN="$TMP_DIR/acli" +fi + +mkdir -p "$INSTALL_DIR" +install -m 755 "$ACLI_BIN" "$INSTALL_DIR/acli" +say "Installed acli to $INSTALL_DIR/acli" +"$INSTALL_DIR/acli" --version || fail "The installed acli binary does not run on this system." + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) + say "" + say "Note: $INSTALL_DIR is not in your PATH. Add it with:" + say " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.$(basename "${SHELL:-bash}")rc" + ;; +esac + +if [ "${ACLI_INSTALL_NO_SETUP:-0}" = "1" ]; then + exit 0 +fi + +say "" +# When piped to sh, stdin is the script itself. Reattach the terminal so +# `acli dev:init` can ask questions; in truly non-interactive contexts (CI), +# print the next step instead of running it. +if [ -t 0 ]; then + exec "$INSTALL_DIR/acli" dev:init +elif [ -e /dev/tty ] && (: /dev/null; then + exec "$INSTALL_DIR/acli" dev:init acceptEnvironmentId() + ->addOption('dir', null, InputOption::VALUE_REQUIRED, 'The directory to clone the application into (defaults to ./)') + ->addUsage('myapp.dev --dir=./myapp --no-interaction') + ->setHelp('This command takes you from nothing to a working local copy of an Acquia application: it authenticates with the Cloud Platform, helps you pick an application and environment, registers an SSH key if needed, clones your code, provisions a local stack with ddev, imports the database and files, and opens the site in your browser.' + . "\n\nPrerequisites: git, Docker, and ddev (the command checks for these and tells you how to install anything missing)." + . "\n\nEvery step is skipped automatically if it is already done, so if it fails partway you can fix the problem and re-run acli dev:init to resume where it left off." + . "\n\nUse acli dev:start and acli dev:stop for the daily start/stop loop; use ddev directly for everything else (drush, logs, ssh)." + . "\n\nFor non-interactive use (CI, scripts), pass the environment ID and credentials: ACLI_KEY=... ACLI_SECRET=... acli dev:init myapp.dev --no-interaction. This requires an SSH key already registered with the Cloud Platform."); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->io->writeln(["Let's get you a local development environment.", '']); + $this->checkPrerequisites(); + $this->ensureAuthenticated($input, $output); + $environment = $this->determineEnvironment($input, $output); + $this->ensureSshKey($input, $environment); + $this->dir = $this->determineTargetDirectory($input, $environment); + $this->ensureCode($environment, $output); + $this->linkApplication($environment); + $this->ensureDdevConfigured($output); + $this->startLocalEnvironment($output); + $this->installComposerDependencies($output); + if ($this->siteIsInstalled()) { + $this->io->writeln('✓ Site database already present — skipping database and file sync. Run acli pull to refresh it.'); + } else { + $dumpPaths = $this->pullDatabase($input, $output, $environment, false, true); + $this->importDatabaseDumps($dumpPaths, $output); + $this->pullFiles($input, $output, $environment); + $this->refreshDrupal($output); + } + $url = $this->getLocalSiteUrl(); + $this->checkSiteResponds($url); + if ($input->isInteractive() && $this->localMachineHelper->isBrowserAvailable()) { + $this->localMachineHelper->startBrowser($url); + } + $this->printSummary($environment, $url); + + return Command::SUCCESS; + } + + /** + * Check for required tools and give one copy-pasteable remedy per missing + * tool. PHP, Composer, Drush, and MySQL are NOT required on the host: + * everything that needs them runs inside the ddev containers. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function checkPrerequisites(): void + { + $isMac = PHP_OS_FAMILY === 'Darwin'; + $remedies = [ + 'ddev' => $isMac ? 'brew install ddev/ddev/ddev' : 'curl -fsSL https://ddev.com/install.sh | bash', + 'docker' => $isMac ? 'brew install --cask docker' : 'curl -fsSL https://get.docker.com | sudo sh', + 'git' => $isMac ? 'xcode-select --install' : 'sudo apt install git', + ]; + $missing = []; + foreach ($remedies as $binary => $remedy) { + if (!$this->localMachineHelper->commandExists($binary)) { + $missing[] = sprintf(' %-8s %s', $binary, $remedy); + } + } + if ($missing) { + throw new AcquiaCliException("Some required tools are missing. Install them with the commands below, then re-run acli dev:init:\n" . implode("\n", $missing)); + } + $process = $this->localMachineHelper->execute(['docker', 'info'], null, null, false, 30); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Docker is installed but not running. Start your Docker provider (Docker Desktop, OrbStack, or `colima start`), then re-run acli dev:init.'); + } + $this->io->writeln('✓ Found git, docker, and ddev'); + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function ensureAuthenticated(InputInterface $input, OutputInterface $output): void + { + if (!$this->cloudApiClientService->isMachineAuthenticated()) { + if (!$input->isInteractive()) { + throw new AcquiaCliException('This machine is not authenticated with the Cloud Platform. Run `acli auth:login` first or set the ACLI_KEY and ACLI_SECRET environment variables.'); + } + $this->io->writeln("First, let's connect to your Acquia Cloud Platform account."); + $exitCode = $this->getApplication()->find('auth:login')->run(new ArrayInput(['command' => 'auth:login']), $output); + if ($exitCode !== Command::SUCCESS) { + throw new AcquiaCliException('Authentication failed.'); + } + } + $account = new Account($this->cloudApiClientService->getClient()); + $this->io->writeln('✓ Authenticated as ' . $account->get()->mail . ''); + } + + /** + * Ensure at least one local SSH key is registered with the Cloud Platform + * so that git cloning and file syncing work. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function ensureSshKey(InputInterface $input, EnvironmentResponse $environment): void + { + $cloudKeys = (new SshKeys($this->cloudApiClientService->getClient()))->getAll(); + foreach ($this->findLocalSshKeys() as $localKey) { + foreach ($cloudKeys as $cloudKey) { + if ($this->publicKeysMatch($cloudKey->public_key, $localKey->getContents())) { + $this->io->writeln('✓ SSH key ' . $localKey->getFilename() . ' is registered with the Cloud Platform'); + return; + } + } + } + // Keys may live only in an SSH agent (e.g. a forwarded agent or the + // 1Password SSH agent) rather than as files in ~/.ssh. + foreach ($this->findSshAgentKeys() as $agentKey) { + foreach ($cloudKeys as $cloudKey) { + if ($this->publicKeysMatch($cloudKey->public_key, $agentKey)) { + $this->io->writeln('✓ An SSH key in your SSH agent is registered with the Cloud Platform'); + return; + } + } + } + if (!$input->isInteractive()) { + throw new AcquiaCliException('No local SSH key is registered with the Cloud Platform. Run `acli ssh-key:create-upload` first.'); + } + $this->io->writeln('You need an SSH key registered with the Cloud Platform to clone your application.'); + if (!$this->io->confirm('Generate a new SSH key and upload it to your Acquia account now?')) { + throw new AcquiaCliException('Register a key with `acli ssh-key:upload` (an existing key) or `acli ssh-key:create-upload` (a new, passphrase-protected key), then re-run `acli dev:init`.'); + } + $this->generateAndUploadSshKey($environment); + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function generateAndUploadSshKey(EnvironmentResponse $environment): void + { + $filepath = Path::join($this->sshDir, 'id_acquia_cli'); + if (file_exists($filepath . '.pub')) { + $this->io->writeln("Using the existing key $filepath.pub"); + } else { + $this->localMachineHelper->checkRequiredBinariesExist(['ssh-keygen']); + // RSA: the Cloud Platform API rejects other key types (ed25519). + $process = $this->localMachineHelper->execute(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '', '-C', 'acli-dev', '-f', $filepath], null, null, false); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to generate an SSH key. {message}', ['message' => $process->getErrorOutput()]); + } + $this->io->writeln("✓ Created $filepath with no passphrase (use `acli ssh-key:create-upload` instead for a passphrase-protected key)"); + } + $publicKey = trim($this->localMachineHelper->readFile($filepath . '.pub')); + $label = preg_replace('/\W/', '', 'acli_dev_' . (gethostname() ?: 'machine')); + (new SshKeys($this->cloudApiClientService->getClient()))->create($label, $publicKey); + $this->io->writeln('✓ Uploaded the key to your Cloud Platform account'); + $this->waitForSshKeyInstallation($environment); + } + + /** + * Poll until the uploaded key actually grants git access; installation on + * the Cloud Platform typically takes a minute or two. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function waitForSshKeyInstallation(EnvironmentResponse $environment): void + { + $vcsUrl = $environment->vcs->url; + // Track success ourselves: LoopHelper also invokes the done callback + // when its watchdog times out, so the callback alone cannot be + // trusted to mean the key is active. The timeout path itself is not + // unit-testable (45-minute watchdog). + // @infection-ignore-all + $active = false; + LoopHelper::getLoopy($this->output, $this->io, 'Waiting for the key to be installed on the Cloud Platform (usually a minute or two)...', function () use ($vcsUrl, &$active): bool { + $process = $this->localMachineHelper->execute(['git', 'ls-remote', $vcsUrl, 'HEAD'], null, null, false, 30, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes']); + $active = $process->isSuccessful(); + return $active; + }, static function (): void { + }); + if (!$active) { + throw new AcquiaCliException('The SSH key was uploaded but is not active yet. Wait a few minutes, then re-run `acli dev:init` — it will resume where it left off.'); + } + $this->io->writeln('✓ SSH key is active'); + } + + /** + * @return string[] Public keys loaded into the SSH agent, if any. + * @infection-ignore-all Defensive output parsing: trimming and filtering + * only guard against blank lines, which change no observable outcome. + */ + private function findSshAgentKeys(): array + { + if (!$this->localMachineHelper->commandExists('ssh-add')) { + return []; + } + $process = $this->localMachineHelper->execute(['ssh-add', '-L'], null, null, false); + if (!$process->isSuccessful()) { + return []; + } + return array_filter(explode("\n", trim($process->getOutput()))); + } + + /** + * Compare only the key type and base64 material: the trailing comment may + * legitimately differ between the agent and the Cloud Platform. + * + * @infection-ignore-all Mutations transform both operands of the equality + * symmetrically, so no black-box comparison test can observe them. + */ + private function publicKeysMatch(string $a, string $b): bool + { + $material = static function (string $key): string { + $parts = preg_split('/\s+/', trim($key)); + return $parts[0] . ' ' . ($parts[1] ?? ''); + }; + return $material($a) === $material($b); + } + + private function determineTargetDirectory(InputInterface $input, EnvironmentResponse $environment): string + { + if ($dir = $input->getOption('dir')) { + return Path::makeAbsolute(Path::canonicalize($dir), getcwd()); + } + $cwd = getcwd(); + // Resuming inside an existing checkout of this application. + if ($this->isEnvironmentCheckout($cwd, $environment)) { + return $cwd; + } + $default = Path::join($cwd, self::getSitegroup($environment)); + // In non-interactive mode ask() returns the default without prompting. + $dir = $this->io->ask('Where should the code be cloned?', $default); + return Path::makeAbsolute(Path::canonicalize($dir), $cwd); + } + + private function isEnvironmentCheckout(string $dir, EnvironmentResponse $environment): bool + { + $gitConfigPath = Path::join($dir, '.git', 'config'); + return file_exists($gitConfigPath) && str_contains($this->localMachineHelper->readFile($gitConfigPath), $environment->vcs->url); + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function ensureCode(EnvironmentResponse $environment, OutputInterface $output): void + { + if (is_dir(Path::join($this->dir, '.git'))) { + if (!$this->isEnvironmentCheckout($this->dir, $environment)) { + throw new AcquiaCliException('{dir} already contains a Git repository that is not a checkout of {url}. Use --dir to choose another directory.', [ + 'dir' => $this->dir, + 'url' => $environment->vcs->url, + ]); + } + $this->io->writeln('✓ Code already cloned to ' . $this->dir . ''); + $this->projectDir = $this->dir; + return; + } + if (is_dir($this->dir) && (new FilesystemIterator($this->dir))->valid()) { + throw new AcquiaCliException('{dir} already exists and is not empty. Use --dir to choose another directory.', ['dir' => $this->dir]); + } + $this->checklist->addItem("Cloning the $environment->name environment's code into $this->dir"); + $this->cloneFromCloud($environment, $this->getOutputCallback($output, $this->checklist)); + $this->checklist->completePreviousItem(); + } + + /** + * Link the checkout to its Cloud application so that later commands + * (acli pull, acli push, ...) know which application this is. + */ + private function linkApplication(EnvironmentResponse $environment): void + { + $configPath = Path::join($this->dir, '.acquia-cli.yml'); + if ($this->localMachineHelper->getFilesystem()->exists($configPath)) { + return; + } + $this->localMachineHelper->getFilesystem()->dumpFile($configPath, Yaml::dump(['cloud_app_uuid' => $environment->application->uuid])); + $this->io->writeln('✓ Linked project to your Cloud application'); + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function ensureDdevConfigured(OutputInterface $output): void + { + if (file_exists(Path::join($this->dir, '.ddev', 'config.yaml'))) { + $this->io->writeln('✓ ddev is already configured'); + return; + } + $this->checklist->addItem('Configuring ddev'); + $process = $this->localMachineHelper->execute(['ddev', 'config', '--auto'], $this->getOutputCallback($output, $this->checklist), $this->dir, false); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to configure ddev. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + } + + /** + * Install Composer dependencies inside the ddev web container so that + * PHP and Composer are not required on the host. + * + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function installComposerDependencies(OutputInterface $output): void + { + if (!file_exists(Path::join($this->dir, 'composer.json'))) { + $this->io->writeln('✓ No composer.json found — skipping dependency install'); + return; + } + if (is_dir(Path::join($this->dir, 'vendor'))) { + $this->io->writeln('✓ Composer dependencies already installed'); + return; + } + $this->checklist->addItem('Installing Composer dependencies (inside ddev)'); + $process = $this->localMachineHelper->execute(['ddev', 'composer', 'install'], $this->getOutputCallback($output, $this->checklist), $this->dir, false, null); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to install Composer dependencies. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + } + + /** + * A fully bootstrappable Drupal site means the database was already + * imported; re-running dev:init should not clobber it. + */ + private function siteIsInstalled(): bool + { + $process = $this->localMachineHelper->execute(['ddev', 'drush', 'status', '--field=bootstrap'], null, $this->dir, false); + return $process->isSuccessful() && str_contains($process->getOutput(), 'Successful'); + } + + /** + * @param string[] $dumpPaths + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function importDatabaseDumps(array $dumpPaths, OutputInterface $output): void + { + $this->localMachineHelper->checkRequiredBinariesExist(['gunzip']); + foreach ($dumpPaths as $dumpPath) { + $this->checklist->addItem('Importing database into ddev'); + // Stream the dump through stdin: ddev's --file import stages the + // dump via the .ddev bind mount, which is unreliable on some + // Docker providers (e.g. colima). + $process = $this->localMachineHelper->executeFromCmd('bash -o pipefail -c "gunzip -c \"$DUMP_FILEPATH\" | ddev import-db"', $this->getOutputCallback($output, $this->checklist), $this->dir, false, null, ['DUMP_FILEPATH' => $dumpPath]); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to import database into ddev. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + // Temp-file cleanup; the dump name contains colons, which cannot + // be created as a real fixture on NTFS to observe the removal. + // @infection-ignore-all + $this->localMachineHelper->getFilesystem()->remove($dumpPath); + } + } + + /** + * Rebuild caches and sanitize the database, like `acli pull` does. Not + * fatal if it fails: the site is usually still usable. + * + * @infection-ignore-all Warn-only diagnostics by design; the drush + * invocations themselves are asserted by the tests. + */ + private function refreshDrupal(OutputInterface $output): void + { + $this->checklist->addItem('Rebuilding Drupal caches and sanitizing the database'); + $callback = $this->getOutputCallback($output, $this->checklist); + $cacheRebuild = $this->localMachineHelper->execute(['ddev', 'drush', 'cache:rebuild', '--yes'], $callback, $this->dir, false, null); + $sanitize = $this->localMachineHelper->execute(['ddev', 'drush', 'sql:sanitize', '--yes'], $callback, $this->dir, false, null); + $this->checklist->completePreviousItem(); + if (!$cacheRebuild->isSuccessful() || !$sanitize->isSuccessful()) { + $this->io->warning('Could not run Drush post-install tasks. Your site may still work — try `ddev drush cache:rebuild` inside ' . $this->dir); + } + } + + private function printSummary(EnvironmentResponse $environment, string $url): void + { + $this->io->success("Your local development environment is ready: $url"); + $lines = [ + 'What you have:', + " Site: $url", + " Code: $this->dir ({$environment->vcs->path} branch, tracking the $environment->label environment)", + '', + 'Next steps:', + ' ddev drush uli Get a one-time login link for your site', + ]; + // A tag-tracking environment does not deploy on push, so only + // advertise git push where it actually deploys. + if (!str_starts_with($environment->vcs->path, 'tags/')) { + $lines[] = " git push Deploy: commit your changes and push — the $environment->label environment runs the {$environment->vcs->path} branch"; + } + $lines[] = ' acli pull Re-sync the database and files from Cloud'; + $lines[] = ' acli dev:stop Stop the local environment (acli dev:start brings it back)'; + $this->io->writeln($lines); + } + + /** + * Projects may use web/ (e.g. Drupal CMS) or another docroot instead of + * Acquia's traditional docroot/; ddev already detected the right one. + */ + protected function getLocalFilesDir(string $site): string + { + $ddevConfigPath = Path::join($this->dir, '.ddev', 'config.yaml'); + if (file_exists($ddevConfigPath)) { + $ddevConfig = Yaml::parseFile($ddevConfigPath); + if (!empty($ddevConfig['docroot'])) { + return Path::join($this->dir, $ddevConfig['docroot'], 'sites', $site, 'files'); + } + } + return parent::getLocalFilesDir($site); + } +} diff --git a/src/Command/Dev/DevStackTrait.php b/src/Command/Dev/DevStackTrait.php new file mode 100644 index 00000000..869fa793 --- /dev/null +++ b/src/Command/Dev/DevStackTrait.php @@ -0,0 +1,76 @@ +getOption('dir') ? Path::makeAbsolute(Path::canonicalize($input->getOption('dir')), getcwd()) : getcwd(); + if (!file_exists(Path::join($dir, '.ddev', 'config.yaml'))) { + throw new AcquiaCliException('No local environment found in {dir}. Run this command from your project directory, or run `acli dev:init` to create one.', ['dir' => $dir]); + } + return $dir; + } + + /** + * @throws \Acquia\Cli\Exception\AcquiaCliException + */ + private function startLocalEnvironment(OutputInterface $output): void + { + $this->checklist->addItem('Starting ddev (the first run may download Docker images)'); + $process = $this->localMachineHelper->execute(['ddev', 'start', '-y'], $this->getOutputCallback($output, $this->checklist), $this->dir, false, null); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to start ddev. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + } + + private function getLocalSiteUrl(): string + { + $process = $this->localMachineHelper->execute(['ddev', 'describe', '-j'], null, $this->dir, false); + if ($process->isSuccessful()) { + $json = json_decode($process->getOutput(), true); + if (is_array($json) && isset($json['raw']['primary_url'])) { + return $json['raw']['primary_url']; + } + } + return 'https://' . basename($this->dir) . '.ddev.site'; + } + + /** + * Verify the site actually serves before telling the user it is ready. + * A warning, not a failure: some sites legitimately need extra local + * steps, and everything else has already succeeded. + */ + private function checkSiteResponds(string $url): void + { + try { + $status = $this->httpClient->request('GET', $url, [ + 'http_errors' => false, + 'timeout' => 30, + 'verify' => false, + ])->getStatusCode(); + } catch (Exception) { + $status = 0; + } + if ($status === 0 || $status >= 400) { + $this->io->warning("The site did not respond as expected at $url (HTTP " . ($status ?: 'no response') . '). The stack is up, but the site may need attention: check `ddev logs -s web` and try `ddev drush uli`.'); + } + } +} diff --git a/src/Command/Dev/DevStartCommand.php b/src/Command/Dev/DevStartCommand.php new file mode 100644 index 00000000..c198e1b9 --- /dev/null +++ b/src/Command/Dev/DevStartCommand.php @@ -0,0 +1,42 @@ +addOption('dir', null, InputOption::VALUE_REQUIRED, 'The project directory (defaults to the current directory)') + ->setHelp('Starts the ddev-based local environment created by acli dev:init and prints the site URL. For everything else — drush, logs, ssh — use ddev directly.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->dir = $this->resolveProjectDir($input); + $this->localMachineHelper->checkRequiredBinariesExist(['ddev']); + $this->startLocalEnvironment($output); + $url = $this->getLocalSiteUrl(); + $this->checkSiteResponds($url); + $this->io->success("Your local site is running: $url"); + $this->io->writeln([ + ' ddev drush uli Get a one-time login link for your site', + ' acli dev:stop Stop the local environment', + ]); + + return Command::SUCCESS; + } +} diff --git a/src/Command/Dev/DevStopCommand.php b/src/Command/Dev/DevStopCommand.php new file mode 100644 index 00000000..5718f013 --- /dev/null +++ b/src/Command/Dev/DevStopCommand.php @@ -0,0 +1,42 @@ +addOption('dir', null, InputOption::VALUE_REQUIRED, 'The project directory (defaults to the current directory)') + ->setHelp('Stops the ddev-based local environment created by acli dev:init. Your code and database are kept; acli dev:start brings the site back.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->dir = $this->resolveProjectDir($input); + $this->localMachineHelper->checkRequiredBinariesExist(['ddev']); + $this->checklist->addItem('Stopping the local environment'); + $process = $this->localMachineHelper->execute(['ddev', 'stop'], $this->getOutputCallback($output, $this->checklist), $this->dir, false, null); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to stop ddev. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + $this->io->success('Local environment stopped. Run `acli dev:start` to bring it back.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 25021439..bee46bf6 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -133,9 +133,11 @@ protected function pullCode(InputInterface $input, OutputInterface $output, bool /** * @param bool $onDemand Force on-demand backup. * @param bool $noImport Skip import. + * @return string[] Local file paths of the downloaded database dumps. When + * $noImport is false, the dumps have already been imported and deleted. * @throws \Acquia\Cli\Exception\AcquiaCliException */ - protected function pullDatabase(InputInterface $input, OutputInterface $output, EnvironmentResponse $sourceEnvironment, bool $onDemand = false, bool $noImport = false, bool $multipleDbs = false): void + protected function pullDatabase(InputInterface $input, OutputInterface $output, EnvironmentResponse $sourceEnvironment, bool $onDemand = false, bool $noImport = false, bool $multipleDbs = false): array { if (!$noImport) { // Verify database connection. @@ -145,6 +147,7 @@ protected function pullDatabase(InputInterface $input, OutputInterface $output, $site = $this->determineSite($sourceEnvironment, $input); $databases = $this->determineCloudDatabases($acquiaCloudClient, $sourceEnvironment, $site, $multipleDbs); + $localFilepaths = []; foreach ($databases as $database) { if ($onDemand) { $this->checklist->addItem("Creating an on-demand database(s) backup on Cloud Platform"); @@ -159,6 +162,7 @@ protected function pullDatabase(InputInterface $input, OutputInterface $output, $this->checklist->addItem("Downloading $database->name database copy from the Cloud Platform"); $localFilepath = $this->downloadDatabaseBackup($sourceEnvironment, $database, $backupResponse, $this->getOutputCallback($output, $this->checklist)); $this->checklist->completePreviousItem(); + $localFilepaths[] = $localFilepath; if ($noImport) { $this->io->success("$database->name database backup downloaded to $localFilepath"); @@ -168,6 +172,11 @@ protected function pullDatabase(InputInterface $input, OutputInterface $output, $this->checklist->completePreviousItem(); } } + + // Single-database callers cannot observe a slice of this list, and + // multi-database callers ignore the return value. + // @infection-ignore-all + return $localFilepaths; } protected function pullFiles(InputInterface $input, OutputInterface $output, EnvironmentResponse $sourceEnvironment): void @@ -573,7 +582,7 @@ protected function determineCloneProject(OutputInterface $output): bool throw new AcquiaCliException('Execute this command from within a Drupal project directory or an empty directory'); } - private function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closure $outputCallback): void + protected function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closure $outputCallback): void { $this->localMachineHelper->checkRequiredBinariesExist(['git']); $command = [ @@ -583,10 +592,13 @@ private function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closure $this->dir, ]; $process = $this->localMachineHelper->execute($command, $outputCallback, null, ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL), null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']); - $this->checkoutBranchFromEnv($chosenEnvironment, $outputCallback); if (!$process->isSuccessful()) { + // Check success before the branch checkout: when the clone fails, + // the target directory does not exist and the checkout would die + // with an unhelpful "cwd does not exist" process error. throw new AcquiaCliException('Failed to clone repository from the Cloud Platform: {message}', ['message' => $process->getErrorOutput()]); } + $this->checkoutBranchFromEnv($chosenEnvironment, $outputCallback); $this->projectDir = $this->dir; } diff --git a/src/EventListener/ExceptionListener.php b/src/EventListener/ExceptionListener.php index 66850b94..5619005f 100644 --- a/src/EventListener/ExceptionListener.php +++ b/src/EventListener/ExceptionListener.php @@ -71,6 +71,9 @@ public function onConsoleError(ConsoleErrorEvent $event): void case 'Could not extract aliases to {destination}': $this->helpMessages[] = 'Check that you have write access to the directory'; break; + case 'Failed to clone repository from the Cloud Platform: {message}': + $this->helpMessages[] = 'Check that your SSH key is registered with the Cloud Platform (run messagesBgColor . ";fg=$this->messagesFgColor;options=bold>acli ssh-key:list). A newly added key can take several minutes to propagate to your application's servers."; + break; case 'Unable to import local database. {message}': $this->helpMessages[] = 'Check for MySQL warnings above or in the server log (/var/log/mysql/error.log)'; $this->helpMessages[] = 'Frequently, `MySQL server has gone away` messages are caused by max_allowed_packet being exceeded.'; diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a60..9ec6eded 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -71,6 +71,10 @@ private function getEnd(): string codestudio codestudio:php-version Change the PHP version in Code Studio codestudio:wizard [cs:wizard] Create and/or configure a new Code Studio project for a given Cloud Platform application + dev + dev:init Set up a complete local development environment for an Acquia application + dev:start Start the local development environment + dev:stop Stop the local development environment env env:certificate-create Install an SSL certificate. env:create Create a new Continuous Delivery Environment (CDE) diff --git a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php new file mode 100644 index 00000000..47b6bed8 --- /dev/null +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -0,0 +1,570 @@ +httpClientProphecy = $this->prophet->prophesize(Client::class); + + return new DevInitCommand( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->httpClientProphecy->reveal() + ); + } + + private function mockPrerequisitesFound(ObjectProphecy $localMachineHelper): void + { + foreach (['git', 'docker', 'ddev'] as $binary) { + $localMachineHelper->commandExists($binary) + ->willReturn(true) + ->shouldBeCalled(); + } + $process = $this->mockProcess(); + $localMachineHelper->execute(['docker', 'info'], null, null, false, 30) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + } + + /** + * Mock a local SSH key whose contents match the given public key. + */ + private function mockLocalSshKey(ObjectProphecy $localMachineHelper, string $publicKey): void + { + $finder = $this->prophet->prophesize(Finder::class); + $finder->files()->willReturn($finder); + $finder->in(Argument::type('string'))->willReturn($finder); + $finder->name('*.pub')->willReturn($finder); + $finder->ignoreUnreadableDirs()->willReturn($finder); + $file = $this->prophet->prophesize(SplFileInfo::class); + $file->getContents()->willReturn($publicKey); + $file->getFilename()->willReturn('id_rsa.pub'); + $finder->getIterator()->willReturn(new ArrayIterator([$file->reveal()])); + $localMachineHelper->getFinder()->willReturn($finder); + } + + private function mockDdev(ObjectProphecy $localMachineHelper, string $dir, bool $siteInstalled): void + { + $process = $this->mockProcess(); + $fs = $this->fs; + $localMachineHelper->execute(['ddev', 'config', '--auto'], Argument::type('callable'), $dir, false) + ->will(function () use ($process, $fs, $dir) { + // Ddev config detects the docroot and records it. + $fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), "name: site\ndocroot: web\n"); + return $process->reveal(); + }); + $localMachineHelper->execute(['ddev', 'start', '-y'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + // Successful process with a non-bootstrapped status exercises the + // output check, not just the exit code. + $drushStatus = $this->mockProcess(); + $drushStatus->getOutput()->willReturn($siteInstalled ? 'Successful' : 'NONE'); + $localMachineHelper->execute(['ddev', 'drush', 'status', '--field=bootstrap'], null, $dir, false) + ->willReturn($drushStatus->reveal()) + ->shouldBeCalled(); + $describe = $this->mockProcess(); + $describe->getOutput()->willReturn(json_encode(['raw' => ['primary_url' => 'https://site.ddev.site']])); + $localMachineHelper->execute(['ddev', 'describe', '-j'], null, $dir, false) + ->willReturn($describe->reveal()) + ->shouldBeCalled(); + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(200); + $this->httpClientProphecy->request('GET', 'https://site.ddev.site', Argument::any()) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + } + + public function testDevInitMissingPrerequisites(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->commandExists('git')->willReturn(false); + $localMachineHelper->commandExists('docker')->willReturn(false); + $localMachineHelper->commandExists('ddev')->willReturn(false); + $isMac = PHP_OS_FAMILY === 'Darwin'; + try { + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); + $this->fail('Expected an AcquiaCliException'); + } catch (AcquiaCliException $exception) { + $message = $exception->getMessage(); + $this->assertStringStartsWith('Some required tools are missing', $message); + // Each missing tool gets the copy-pasteable remedy for this OS. + $this->assertStringContainsString($isMac ? 'brew install ddev/ddev/ddev' : 'https://ddev.com/install.sh', $message); + $this->assertStringContainsString($isMac ? 'brew install --cask docker' : 'https://get.docker.com', $message); + $this->assertStringContainsString($isMac ? 'xcode-select --install' : 'sudo apt install git', $message); + } + } + + public function testDevInitDockerNotRunning(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + foreach (['git', 'docker', 'ddev'] as $binary) { + $localMachineHelper->commandExists($binary)->willReturn(true); + } + $process = $this->mockProcess(false); + $localMachineHelper->execute(['docker', 'info'], null, null, false, 30) + ->willReturn($process->reveal()); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Docker is installed but not running'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); + } + + public function testDevInitNotAuthenticatedNonInteractive(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->clientServiceProphecy->isMachineAuthenticated()->willReturn(false); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('This machine is not authenticated with the Cloud Platform'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + public function testDevInitNoSshKeyNonInteractive(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->mockRequest('getEnvironment', self::$environmentId); + $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, 'ssh-rsa KeyNotOnTheCloudPlatform'); + $localMachineHelper->commandExists('ssh-add')->willReturn(false); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('No local SSH key is registered with the Cloud Platform'); + $this->executeCommand([ + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * With no registered SSH key, dev:init generates one, uploads it, and + * waits for it to become active — no hand-off to other commands. + */ + public function testDevInitGeneratesAndUploadsSshKey(): void + { + $dir = Path::join($this->projectDir, 'site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, 'ssh-rsa KeyNotOnTheCloudPlatform'); + $localMachineHelper->commandExists('ssh-add')->willReturn(false); + + // Key generation. + $keyPath = Path::join($this->sshDir, 'id_acquia_cli'); + $localMachineHelper->checkRequiredBinariesExist(['ssh-keygen']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute(['ssh-keygen', '-t', 'rsa', '-b', '4096', '-N', '', '-C', 'acli-dev', '-f', $keyPath], null, null, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $sshKeysRequestBody = self::getMockRequestBodyFromSpec('/account/ssh-keys'); + // Trailing newline: the upload must send the trimmed key material. + $localMachineHelper->readFile($keyPath . '.pub') + ->willReturn($sshKeysRequestBody['public_key'] . "\n"); + + // Upload and installation polling. + $this->mockRequest('postAccountSshKeys', null, [ + 'json' => [ + 'label' => preg_replace('/\W/', '', 'acli_dev_' . (gethostname() ?: 'machine')), + 'public_key' => $sshKeysRequestBody['public_key'], + ], + ]); + $localMachineHelper->execute(['git', 'ls-remote', $environment->vcs->url, 'HEAD'], null, null, false, 30, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + // End the test at the clone: the key flow above is what is under test. + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $failedClone = $this->mockProcess(false); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($failedClone->reveal()) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to clone repository from the Cloud Platform'); + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [ + // Generate a new SSH key and upload it to your Acquia account now? + 'y', + ], OutputInterface::VERBOSITY_NORMAL); + } + + /** + * A previously generated key that never made it to the Cloud Platform is + * reused and uploaded instead of erroring or generating another one. + */ + public function testDevInitReusesExistingGeneratedKey(): void + { + $dir = Path::join($this->projectDir, 'site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, 'ssh-rsa KeyNotOnTheCloudPlatform'); + $localMachineHelper->commandExists('ssh-add')->willReturn(false); + + $keyPath = Path::join($this->sshDir, 'id_acquia_cli'); + $this->fs->dumpFile($keyPath . '.pub', 'ssh-rsa ExistingKey'); + $localMachineHelper->execute(Argument::withEntry(0, 'ssh-keygen'), Argument::cetera()) + ->shouldNotBeCalled(); + $sshKeysRequestBody = self::getMockRequestBodyFromSpec('/account/ssh-keys'); + $localMachineHelper->readFile($keyPath . '.pub') + ->willReturn($sshKeysRequestBody['public_key']); + $this->mockRequest('postAccountSshKeys', null, [ + 'json' => [ + 'label' => preg_replace('/\W/', '', 'acli_dev_' . (gethostname() ?: 'machine')), + 'public_key' => $sshKeysRequestBody['public_key'], + ], + ]); + $process = $this->mockProcess(); + $localMachineHelper->execute(['git', 'ls-remote', $environment->vcs->url, 'HEAD'], null, null, false, 30, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes']) + ->willReturn($process->reveal()); + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $failedClone = $this->mockProcess(false); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($failedClone->reveal()) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to clone repository from the Cloud Platform'); + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [ + // Generate a new SSH key and upload it to your Acquia account now? + 'y', + ], OutputInterface::VERBOSITY_NORMAL); + } + + /** + * A non-matching agent key is not good enough: non-interactive mode still + * fails with the remedy. + */ + public function testDevInitNoMatchingAgentKeyNonInteractive(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $this->mockRequest('getEnvironment', self::$environmentId); + $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, 'ssh-rsa KeyNotOnTheCloudPlatform'); + $localMachineHelper->commandExists('ssh-add')->willReturn(true); + $agentList = $this->mockProcess(); + $agentList->getOutput()->willReturn("ssh-rsa AnotherKeyNotOnTheCloudPlatform agent\n"); + $localMachineHelper->execute(['ssh-add', '-L'], null, null, false) + ->willReturn($agentList->reveal()); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('No local SSH key is registered with the Cloud Platform'); + $this->executeCommand([ + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * Without --dir, dev:init confirms the clone directory interactively with a + * derived default, and clones into whatever the user answers. + */ + public function testDevInitPromptsForCloneDirectory(): void + { + $answeredDir = Path::join($this->projectDir, 'my-custom-dir'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $localMachineHelper->readFile(Argument::type('string'))->willReturn(''); + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $process = $this->mockProcess(false); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $answeredDir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to clone repository from the Cloud Platform'); + $this->executeCommand([ + 'environmentId' => self::$environmentId, + ], [ + // Where should the code be cloned? + $answeredDir, + ], OutputInterface::VERBOSITY_NORMAL); + } + + /** + * A failed clone must throw the clone error, not attempt the branch + * checkout in a directory that does not exist. + */ + public function testDevInitCloneFailure(): void + { + $dir = Path::join($this->projectDir, 'site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $process = $this->mockProcess(false); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'git', + 'checkout', + $environment->vcs->path, + ], Argument::cetera()) + ->shouldNotBeCalled(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Failed to clone repository from the Cloud Platform'); + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL, false); + } + + /** + * From nothing to a working site, non-interactively: clone, configure + * ddev, start it, import the database, sync files. + */ + public function testDevInitFreshNonInteractive(): void + { + $dir = Path::join($this->projectDir, 'site'); + // An existing but empty target directory is fine to clone into. + $this->fs->mkdir($dir); + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $account = $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $this->mockGetFilesystem($localMachineHelper); + + // Clone. + $localMachineHelper->checkRequiredBinariesExist(['git']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute([ + 'git', + 'clone', + $environment->vcs->url, + $dir, + ], Argument::type('callable'), null, false, null, ['GIT_SSH_COMMAND' => 'ssh -o StrictHostKeyChecking=accept-new']) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'git', + 'checkout', + $environment->vcs->path, + ], Argument::type('callable'), $dir, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->mockDdev($localMachineHelper, $dir, false); + + // Database download and import. + $sshHelper = $this->mockSshHelper(); + $this->mockListSites($sshHelper); + $this->command->sshHelper = $sshHelper->reveal(); + $this->mockGetBackup($environment); + $dumpPath = Path::join(sys_get_temp_dir(), 'dev-my_db-my_dbdev-2012-05-15T12:00:00Z.sql.gz'); + $localMachineHelper->checkRequiredBinariesExist(['gunzip']) + ->shouldBeCalled(); + $localMachineHelper->executeFromCmd('bash -o pipefail -c "gunzip -c \"$DUMP_FILEPATH\" | ddev import-db"', Argument::type('callable'), $dir, false, null, ['DUMP_FILEPATH' => $dumpPath]) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + // Files. + $localMachineHelper->checkRequiredBinariesExist(['rsync']) + ->shouldBeCalled(); + $localMachineHelper->execute([ + 'rsync', + '-avPhze', + 'ssh -o StrictHostKeyChecking=accept-new', + $environment->ssh_url . ':/mnt/files/site.dev/sites/default/files/', + Path::join($dir, 'web', 'sites', 'default', 'files'), + ], Argument::type('callable'), null, false) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + // Drush cache rebuild and sanitization. + $localMachineHelper->execute(['ddev', 'drush', 'cache:rebuild', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $localMachineHelper->execute(['ddev', 'drush', 'sql:sanitize', '--yes'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL, false); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('✓ Found git, docker, and ddev', $output); + $this->assertStringContainsString('✓ Authenticated as ' . $account->mail, $output); + $this->assertStringContainsString('✓ SSH key id_rsa.pub is registered with the Cloud Platform', $output); + $this->assertStringContainsString('Your local development environment is ready: https://site.ddev.site', $output); + $this->assertStringContainsString('What you have:', $output); + $this->assertStringContainsString('ddev drush uli', $output); + $this->assertStringContainsString('git push', $output); + $this->assertStringContainsString('runs the master branch', $output); + $this->assertStringNotContainsString('Could not run Drush post-install tasks', $output); + // The project was linked to the Cloud application. + $this->assertFileExists(Path::join($dir, '.acquia-cli.yml')); + $this->assertStringContainsString($environment->application->uuid, file_get_contents(Path::join($dir, '.acquia-cli.yml'))); + } + + /** + * Re-running dev:init on an existing checkout with an installed site skips + * every completed step instead of redoing it. + */ + public function testDevInitResumeSkipsCompletedSteps(): void + { + $dir = $this->projectDir; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $sshKeys = $this->mockRequest('getAccountSshKeys'); + $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); + $this->mockGetFilesystem($localMachineHelper); + $localMachineHelper->isBrowserAvailable()->willReturn(false); + + // An existing checkout of this application with ddev already + // configured, composer dependencies installed, and the app linked. + $this->fs->dumpFile(Path::join($dir, '.git', 'config'), 'url = ' . $environment->vcs->url); + $localMachineHelper->readFile(Path::join($dir, '.git', 'config')) + ->willReturn('url = ' . $environment->vcs->url); + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $this->fs->dumpFile(Path::join($dir, 'composer.json'), '{}'); + $this->fs->mkdir(Path::join($dir, 'vendor')); + $linkFile = Path::join($dir, '.acquia-cli.yml'); + $this->fs->dumpFile($linkFile, "cloud_app_uuid: sentinel\n"); + $localMachineHelper->execute(['ddev', 'config', '--auto'], Argument::cetera()) + ->shouldNotBeCalled(); + + $this->mockDdev($localMachineHelper, $dir, true); + + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL); + + $output = $this->getDisplay(); + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('✓ Code already cloned to ' . $dir, $output); + $this->assertStringContainsString('✓ ddev is already configured', $output); + $this->assertStringContainsString('✓ Composer dependencies already installed', $output); + $this->assertStringContainsString('✓ Site database already present', $output); + $this->assertStringContainsString('Your local development environment is ready: https://site.ddev.site', $output); + // The existing link file is left untouched. + $this->assertSame("cloud_app_uuid: sentinel\n", file_get_contents($linkFile)); + } + + /** + * A key that exists only in the SSH agent (with a different comment than + * the Cloud copy) satisfies the key check. + */ + public function testDevInitAcceptsSshAgentKey(): void + { + $dir = $this->projectDir; + $localMachineHelper = $this->mockLocalMachineHelper(); + $this->mockPrerequisitesFound($localMachineHelper); + $this->mockRequest('getAccount'); + $environment = $this->mockRequest('getEnvironment', self::$environmentId); + $sshKeys = $this->mockRequest('getAccountSshKeys'); + // No local key file matches... + $this->mockLocalSshKey($localMachineHelper, 'ssh-rsa KeyNotOnTheCloudPlatform'); + // ...but the agent holds the registered key, with its own comment. + $keyMaterial = implode(' ', array_slice(explode(' ', trim($sshKeys[0]->public_key)), 0, 2)); + $localMachineHelper->commandExists('ssh-add')->willReturn(true) + ->shouldBeCalled(); + $agentList = $this->mockProcess(); + $agentList->getOutput()->willReturn($keyMaterial . " agent-comment\n"); + $localMachineHelper->execute(['ssh-add', '-L'], null, null, false) + ->willReturn($agentList->reveal()) + ->shouldBeCalled(); + $this->mockGetFilesystem($localMachineHelper); + $localMachineHelper->isBrowserAvailable()->willReturn(false); + $this->fs->dumpFile(Path::join($dir, '.git', 'config'), 'url = ' . $environment->vcs->url); + $localMachineHelper->readFile(Path::join($dir, '.git', 'config')) + ->willReturn('url = ' . $environment->vcs->url); + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $this->fs->dumpFile(Path::join($dir, 'composer.json'), '{}'); + $this->fs->mkdir(Path::join($dir, 'vendor')); + $this->mockDdev($localMachineHelper, $dir, true); + + $this->executeCommand([ + '--dir' => $dir, + 'environmentId' => self::$environmentId, + ], [], OutputInterface::VERBOSITY_NORMAL); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('✓ An SSH key in your SSH agent is registered with the Cloud Platform', $this->getDisplay()); + } +} diff --git a/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php b/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php new file mode 100644 index 00000000..8179fc93 --- /dev/null +++ b/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php @@ -0,0 +1,176 @@ +httpClientProphecy = $this->prophet->prophesize(Client::class); + $class = $this->startCommand ? DevStartCommand::class : DevStopCommand::class; + + return new $class( + $this->localMachineHelper, + $this->datastoreCloud, + $this->datastoreAcli, + $this->cloudCredentials, + $this->telemetryHelper, + $this->acliRepoRoot, + $this->clientServiceProphecy->reveal(), + $this->sshHelper, + $this->sshDir, + $this->logger, + $this->selfUpdateManager, + $this->httpClientProphecy->reveal() + ); + } + + public function testDevStart(): void + { + $dir = $this->projectDir; + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ddev']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute(['ddev', 'start', '-y'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $describe = $this->mockProcess(); + $describe->getOutput()->willReturn(json_encode(['raw' => ['primary_url' => 'https://site.ddev.site']])); + $localMachineHelper->execute(['ddev', 'describe', '-j'], null, $dir, false) + ->willReturn($describe->reveal()) + ->shouldBeCalled(); + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(200); + $this->httpClientProphecy->request('GET', 'https://site.ddev.site', [ + 'http_errors' => false, + 'timeout' => 30, + 'verify' => false, + ]) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + + $this->executeCommand(['--dir' => $dir], [], OutputInterface::VERBOSITY_NORMAL); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Your local site is running: https://site.ddev.site', $this->getDisplay()); + $this->assertStringContainsString('ddev drush uli', $this->getDisplay()); + $this->assertStringContainsString('acli dev:stop', $this->getDisplay()); + $this->assertStringNotContainsString('did not respond as expected', $this->getDisplay()); + } + + /** + * An unreachable site (request exception) warns rather than crashing. + */ + public function testDevStartSiteUnreachable(): void + { + $dir = $this->projectDir; + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ddev']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute(['ddev', 'start', '-y'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $describe = $this->mockProcess(); + $describe->getOutput()->willReturn(json_encode(['raw' => ['primary_url' => 'https://site.ddev.site']])); + $localMachineHelper->execute(['ddev', 'describe', '-j'], null, $dir, false) + ->willReturn($describe->reveal()) + ->shouldBeCalled(); + $this->httpClientProphecy->request('GET', 'https://site.ddev.site', Argument::type('array')) + ->willThrow(new \Exception('Connection refused')) + ->shouldBeCalled(); + + $this->executeCommand(['--dir' => $dir], [], OutputInterface::VERBOSITY_NORMAL); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('did not respond as expected at https://site.ddev.site (HTTP no response)', $this->getDisplay()); + } + + /** + * When `ddev describe` gives no usable JSON, the URL falls back to the + * conventional .ddev.site name; an error status from the site + * produces a warning with next steps rather than a claimed success. + */ + public function testDevStartUrlFallbackAndUnhealthySite(): void + { + $dir = $this->projectDir; + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ddev']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute(['ddev', 'start', '-y'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $describe = $this->mockProcess(); + // Valid JSON without the expected key still falls back. + $describe->getOutput()->willReturn(json_encode(['raw' => ['status' => 'running']])); + $localMachineHelper->execute(['ddev', 'describe', '-j'], null, $dir, false) + ->willReturn($describe->reveal()) + ->shouldBeCalled(); + $fallbackUrl = 'https://' . basename($dir) . '.ddev.site'; + $response = $this->prophet->prophesize(ResponseInterface::class); + $response->getStatusCode()->willReturn(400); + $this->httpClientProphecy->request('GET', $fallbackUrl, [ + 'http_errors' => false, + 'timeout' => 30, + 'verify' => false, + ]) + ->willReturn($response->reveal()) + ->shouldBeCalled(); + + $this->executeCommand(['--dir' => $dir], [], OutputInterface::VERBOSITY_NORMAL); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString("did not respond as expected at $fallbackUrl (HTTP 400)", $this->getDisplay()); + } + + public function testDevStartWithoutProject(): void + { + $this->mockLocalMachineHelper(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('No local environment found in ' . $this->projectDir . '. Run this command from your project directory, or run `acli dev:init` to create one.'); + $this->executeCommand(['--dir' => $this->projectDir], [], OutputInterface::VERBOSITY_NORMAL); + } + + public function testDevStop(): void + { + $this->startCommand = false; + $this->command = $this->createCommand(); + $dir = $this->projectDir; + $this->fs->dumpFile(Path::join($dir, '.ddev', 'config.yaml'), 'name: site'); + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->checkRequiredBinariesExist(['ddev']) + ->shouldBeCalled(); + $process = $this->mockProcess(); + $localMachineHelper->execute(['ddev', 'stop'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + + $this->executeCommand(['--dir' => $dir], [], OutputInterface::VERBOSITY_NORMAL); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Local environment stopped. Run `acli dev:start` to bring it back.', $this->getDisplay()); + } +}