From b39890d0485882d70b19c743cae95d8b8a0191da Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 10:17:24 +0300 Subject: [PATCH 01/11] Add acli setup: one command from nothing to a local dev environment - New top-level setup command that authenticates, picks an application and environment, ensures an SSH key (files or agent), clones code, provisions ddev, imports database and files, and opens the site. Every step no-ops when already done, so re-running resumes after a failure. - Host prerequisites are only git, Docker, and ddev, with one copy-pasteable remedy per missing tool; PHP/Composer/Drush/MySQL all run inside ddev. - install.sh bootstrap installs the native acli release build (no PHP required on macOS arm64/Linux x86_64) with sha256 verification, then runs acli setup. CI now publishes .sha256 files with release assets. - PullCommandBase::pullDatabase() now returns downloaded dump paths so callers can import them through ddev. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 12 +- README.md | 10 + install.sh | 108 +++++ src/Command/App/SetupCommand.php | 377 ++++++++++++++++++ src/Command/Pull/PullCommandBase.php | 10 +- src/EventListener/ExceptionListener.php | 3 + tests/phpunit/src/Application/KernelTest.php | 1 + .../src/Commands/App/SetupCommandTest.php | 280 +++++++++++++ 8 files changed, 797 insertions(+), 4 deletions(-) create mode 100755 install.sh create mode 100644 src/Command/App/SetupCommand.php create mode 100644 tests/phpunit/src/Commands/App/SetupCommandTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb13c8b6..c1d828a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,9 @@ jobs: # Warm the symfony cache so it gets bundled with phar. ./bin/acli composer box-compile + - name: Generate checksum + run: shasum -a 256 acli.phar > acli.phar.sha256 + working-directory: var - name: Store artifact in Actions uses: actions/upload-artifact@v7 with: @@ -139,7 +142,9 @@ jobs: uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') with: - files: var/acli.phar + files: | + var/acli.phar + var/acli.phar.sha256 - name: Publish docs if: github.event_name == 'push' run: | @@ -180,6 +185,7 @@ jobs: ./spc micro:combine acli.phar -M micro.sfx -O acli -I "memory_limit=2G" chmod +x acli tar -czvf native-acli-${{ matrix.platform }}.tar.gz acli + shasum -a 256 native-acli-${{ matrix.platform }}.tar.gz > native-acli-${{ matrix.platform }}.tar.gz.sha256 - name: "Upload Artifact" uses: actions/upload-artifact@v7 with: @@ -189,7 +195,9 @@ jobs: uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') with: - files: native-acli-${{ matrix.platform }}.tar.gz + files: | + native-acli-${{ matrix.platform }}.tar.gz + native-acli-${{ matrix.platform }}.tar.gz.sha256 # Require all checks to pass without having to enumerate them in the branch protection UI. # @see https://github.community/t/is-it-possible-to-require-all-github-actions-tasks-to-pass-without-enumerating-them/117957 diff --git a/README.md b/README.md index 17c416ae..4bcca876 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 setup`, 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 setup`. See `acli help setup` 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/install.sh b/install.sh new file mode 100755 index 00000000..89f79350 --- /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 setup`, +# 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 setup` 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)" +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 setup` 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" setup +elif [ -e /dev/tty ] && (: /dev/null; then + exec "$INSTALL_DIR/acli" setup 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 setup fails partway you can fix the problem and re-run acli setup to resume where it left off." + . "\n\nFor non-interactive use (CI, scripts), pass the environment ID and credentials: ACLI_KEY=... ACLI_SECRET=... acli setup 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, $output); + $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(); + 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 setup:\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 setup.'); + } + $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, OutputInterface $output): 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.'); + $exitCode = $this->getApplication()->find('ssh-key:create-upload')->run(new ArrayInput(['command' => 'ssh-key:create-upload']), $output); + if ($exitCode !== Command::SUCCESS) { + throw new AcquiaCliException('SSH key setup failed.'); + } + } + + /** + * @return string[] Public keys loaded into the SSH agent, if any. + */ + 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. + */ + 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; + } + return Path::join($cwd, self::getSitegroup($environment)); + } + + 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(); + } + + /** + * @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(); + } + + /** + * 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 setup 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 + { + foreach ($dumpPaths as $dumpPath) { + $this->checklist->addItem('Importing database into ddev'); + $process = $this->localMachineHelper->execute(['ddev', 'import-db', '--file=' . $dumpPath], $this->getOutputCallback($output, $this->checklist), $this->dir, false, null); + if (!$process->isSuccessful()) { + throw new AcquiaCliException('Unable to import database into ddev. {message}', ['message' => $process->getErrorOutput()]); + } + $this->checklist->completePreviousItem(); + $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. + */ + 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 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'; + } + + private function printSummary(EnvironmentResponse $environment, string $url): void + { + $this->io->success("Your local development environment is ready: $url"); + $this->io->writeln([ + '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', + ' acli pull Re-sync the database and files from Cloud', + ' ddev stop Stop the local environment', + ]); + } + + /** + * ddev projects may use web/ as the docroot (e.g. Drupal CMS) instead of + * Acquia's traditional docroot/. + */ + protected function getLocalFilesDir(string $site): string + { + if (!is_dir(Path::join($this->dir, 'docroot')) && is_dir(Path::join($this->dir, 'web'))) { + return Path::join($this->dir, 'web', 'sites', $site, 'files'); + } + return parent::getLocalFilesDir($site); + } +} diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 25021439..53b6459d 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,8 @@ protected function pullDatabase(InputInterface $input, OutputInterface $output, $this->checklist->completePreviousItem(); } } + + return $localFilepaths; } protected function pullFiles(InputInterface $input, OutputInterface $output, EnvironmentResponse $sourceEnvironment): void @@ -573,7 +579,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 = [ 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..0bbaf17d 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -43,6 +43,7 @@ private function getStart(): string docs Open Acquia product documentation in a web browser help Display help for a command list [self:list] List commands + setup Set up a complete local development environment for an Acquia application acsf acsf:list [acsf] List all Acquia Cloud Site Factory commands api diff --git a/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php new file mode 100644 index 00000000..506c626e --- /dev/null +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -0,0 +1,280 @@ +httpClientProphecy = $this->prophet->prophesize(Client::class); + + return new SetupCommand( + $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(); + $localMachineHelper->execute(['ddev', 'config', '--auto'], Argument::type('callable'), $dir, false) + ->willReturn($process->reveal()); + $localMachineHelper->execute(['ddev', 'start', '-y'], Argument::type('callable'), $dir, false, null) + ->willReturn($process->reveal()) + ->shouldBeCalled(); + $drushStatus = $this->mockProcess($siteInstalled); + $drushStatus->getOutput()->willReturn($siteInstalled ? 'Successful' : ''); + $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(); + } + + public function testSetupMissingPrerequisites(): void + { + $localMachineHelper = $this->mockLocalMachineHelper(); + $localMachineHelper->commandExists('git')->willReturn(true); + $localMachineHelper->commandExists('docker')->willReturn(false); + $localMachineHelper->commandExists('ddev')->willReturn(false); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('Some required tools are missing'); + $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); + } + + public function testSetupDockerNotRunning(): 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 testSetupNotAuthenticatedNonInteractive(): 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 testSetupNoSshKeyNonInteractive(): 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); + } + + /** + * From nothing to a working site, non-interactively: clone, configure + * ddev, start it, import the database, sync files. + */ + public function testSetupFreshNonInteractive(): 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); + $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->execute(['ddev', 'import-db', '--file=' . $dumpPath], Argument::type('callable'), $dir, false, null) + ->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/', + $dir . '/docroot/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', $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('ddev drush uli', $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 setup on an existing checkout with an installed site skips + * every completed step instead of redoing it. + */ + public function testSetupResumeSkipsCompletedSteps(): 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 and composer dependencies already installed. + $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); + + $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); + } +} From b62ef7832134af25bb5bea5e79cdf103036aafb6 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 10:27:56 +0300 Subject: [PATCH 02/11] Fix clone failure handling and stream ddev database imports Live end-to-end testing caught two bugs: - cloneFromCloud() ran the branch checkout before checking whether git clone succeeded, turning a failed clone into an unhelpful 'cwd does not exist' process error instead of the intended message. - ddev import-db --file stages the dump through the .ddev bind mount, which fails on some Docker providers (e.g. colima); stream the dump through stdin instead. Also recognize SSH keys that exist only in an SSH agent (e.g. the 1Password agent) when checking Cloud Platform key registration. Co-Authored-By: Claude Fable 5 --- src/Command/App/SetupCommand.php | 6 ++- src/Command/Pull/PullCommandBase.php | 5 ++- .../src/Commands/App/SetupCommandTest.php | 43 ++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/Command/App/SetupCommand.php b/src/Command/App/SetupCommand.php index cd30786b..eb2dbaca 100644 --- a/src/Command/App/SetupCommand.php +++ b/src/Command/App/SetupCommand.php @@ -309,9 +309,13 @@ private function siteIsInstalled(): bool */ private function importDatabaseDumps(array $dumpPaths, OutputInterface $output): void { + $this->localMachineHelper->checkRequiredBinariesExist(['gunzip']); foreach ($dumpPaths as $dumpPath) { $this->checklist->addItem('Importing database into ddev'); - $process = $this->localMachineHelper->execute(['ddev', 'import-db', '--file=' . $dumpPath], $this->getOutputCallback($output, $this->checklist), $this->dir, false, null); + // 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()]); } diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index 53b6459d..f4efead7 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -589,10 +589,13 @@ protected function cloneFromCloud(EnvironmentResponse $chosenEnvironment, Closur $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/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php index 506c626e..879cd712 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -152,6 +152,44 @@ public function testSetupNoSshKeyNonInteractive(): void ], [], OutputInterface::VERBOSITY_NORMAL, false); } + /** + * A failed clone must throw the clone error, not attempt the branch + * checkout in a directory that does not exist. + */ + public function testSetupCloneFailure(): 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. @@ -195,7 +233,10 @@ public function testSetupFreshNonInteractive(): void $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->execute(['ddev', 'import-db', '--file=' . $dumpPath], Argument::type('callable'), $dir, false, null) + $this->fs->dumpFile($dumpPath, 'fake dump'); + $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(); From d45d0b0a6e0dca3e83ce8b64c70d66a8df0ac2e7 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 10:34:58 +0300 Subject: [PATCH 03/11] Verify the site responds before declaring setup complete A stack can come up with the site still broken (e.g. Docker providers that fail to share the project path produce a fallback nginx config that 404s everything). Warn with next steps instead of claiming success. Co-Authored-By: Claude Fable 5 --- src/Command/App/SetupCommand.php | 23 +++++++++++++++++++ .../src/Commands/App/SetupCommandTest.php | 6 +++++ 2 files changed, 29 insertions(+) diff --git a/src/Command/App/SetupCommand.php b/src/Command/App/SetupCommand.php index eb2dbaca..cb627c2b 100644 --- a/src/Command/App/SetupCommand.php +++ b/src/Command/App/SetupCommand.php @@ -10,6 +10,7 @@ use AcquiaCloudApi\Endpoints\Account; use AcquiaCloudApi\Endpoints\SshKeys; use AcquiaCloudApi\Response\EnvironmentResponse; +use Exception; use FilesystemIterator; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -59,6 +60,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->refreshDrupal($output); } $url = $this->getLocalSiteUrl(); + $this->checkSiteResponds($url); if ($input->isInteractive() && $this->localMachineHelper->isBrowserAvailable()) { $this->localMachineHelper->startBrowser($url); } @@ -352,6 +354,27 @@ private function getLocalSiteUrl(): string 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`.'); + } + } + private function printSummary(EnvironmentResponse $environment, string $url): void { $this->io->success("Your local development environment is ready: $url"); diff --git a/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php index 879cd712..de7099d9 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -13,6 +13,7 @@ use GuzzleHttp\Client; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Filesystem\Path; use Symfony\Component\Finder\Finder; @@ -99,6 +100,11 @@ private function mockDdev(ObjectProphecy $localMachineHelper, string $dir, bool $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 testSetupMissingPrerequisites(): void From da8ff881d13ba38d416f605d3873b90d81e14633 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 10:37:44 +0300 Subject: [PATCH 04/11] Drop ci.yml checksum publishing from this branch The push credential for this fork lacks the workflow scope. The two-line checksum change is carried in the PR description for someone with workflow permissions to apply; install.sh already handles releases without checksums gracefully. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1d828a0..bb13c8b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,9 +130,6 @@ jobs: # Warm the symfony cache so it gets bundled with phar. ./bin/acli composer box-compile - - name: Generate checksum - run: shasum -a 256 acli.phar > acli.phar.sha256 - working-directory: var - name: Store artifact in Actions uses: actions/upload-artifact@v7 with: @@ -142,9 +139,7 @@ jobs: uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') with: - files: | - var/acli.phar - var/acli.phar.sha256 + files: var/acli.phar - name: Publish docs if: github.event_name == 'push' run: | @@ -185,7 +180,6 @@ jobs: ./spc micro:combine acli.phar -M micro.sfx -O acli -I "memory_limit=2G" chmod +x acli tar -czvf native-acli-${{ matrix.platform }}.tar.gz acli - shasum -a 256 native-acli-${{ matrix.platform }}.tar.gz > native-acli-${{ matrix.platform }}.tar.gz.sha256 - name: "Upload Artifact" uses: actions/upload-artifact@v7 with: @@ -195,9 +189,7 @@ jobs: uses: softprops/action-gh-release@v3 if: startsWith(github.ref, 'refs/tags/') with: - files: | - native-acli-${{ matrix.platform }}.tar.gz - native-acli-${{ matrix.platform }}.tar.gz.sha256 + files: native-acli-${{ matrix.platform }}.tar.gz # Require all checks to pass without having to enumerate them in the branch protection UI. # @see https://github.community/t/is-it-possible-to-require-all-github-actions-tasks-to-pass-without-enumerating-them/117957 From 1df7856d438d801383561dd186edcfc85bcf952e Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 10:41:04 +0300 Subject: [PATCH 05/11] Do not create a colon-named fixture file in SetupCommandTest Colons are invalid in NTFS filenames, so the fixture broke the Windows CI job. The file is unnecessary: Filesystem::remove() is a no-op for missing paths. Co-Authored-By: Claude Fable 5 --- tests/phpunit/src/Commands/App/SetupCommandTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php index de7099d9..41ccc7b4 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -239,7 +239,6 @@ public function testSetupFreshNonInteractive(): void $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'); - $this->fs->dumpFile($dumpPath, 'fake dump'); $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]) From 7914f1e8e82ae5b490a9265787be5f04d212d32a Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 11:14:35 +0300 Subject: [PATCH 06/11] Confirm the clone directory interactively with an overridable default Instead of silently deriving the target directory, setup now asks where to clone, prefilled with ./ so Enter accepts the default. --dir and non-interactive runs skip the prompt as before. Co-Authored-By: Claude Fable 5 --- src/Command/App/SetupCommand.php | 5 ++- .../src/Commands/App/SetupCommandTest.php | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Command/App/SetupCommand.php b/src/Command/App/SetupCommand.php index cb627c2b..62c02806 100644 --- a/src/Command/App/SetupCommand.php +++ b/src/Command/App/SetupCommand.php @@ -194,7 +194,10 @@ private function determineTargetDirectory(InputInterface $input, EnvironmentResp if ($this->isEnvironmentCheckout($cwd, $environment)) { return $cwd; } - return Path::join($cwd, self::getSitegroup($environment)); + $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 diff --git a/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php index 41ccc7b4..9c4c78ca 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -158,6 +158,41 @@ public function testSetupNoSshKeyNonInteractive(): void ], [], OutputInterface::VERBOSITY_NORMAL, false); } + /** + * Without --dir, setup confirms the clone directory interactively with a + * derived default, and clones into whatever the user answers. + */ + public function testSetupPromptsForCloneDirectory(): 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. From 47abf274a23fd42074d6f3aaa96d4e73791d5523 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Wed, 5 Aug 2026 14:12:49 +0300 Subject: [PATCH 07/11] Tell users how to deploy their changes in the setup summary The next steps now explain that committing and pushing deploys, since the cloned branch is what the chosen environment runs. Omitted for tag-tracking environments, where a push does not deploy. Co-Authored-By: Claude Fable 5 --- src/Command/App/SetupCommand.php | 14 ++++++++++---- .../phpunit/src/Commands/App/SetupCommandTest.php | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Command/App/SetupCommand.php b/src/Command/App/SetupCommand.php index 62c02806..4e43f232 100644 --- a/src/Command/App/SetupCommand.php +++ b/src/Command/App/SetupCommand.php @@ -381,16 +381,22 @@ private function checkSiteResponds(string $url): void private function printSummary(EnvironmentResponse $environment, string $url): void { $this->io->success("Your local development environment is ready: $url"); - $this->io->writeln([ + $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', - ' acli pull Re-sync the database and files from Cloud', - ' ddev stop Stop the local environment', - ]); + ]; + // 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[] = ' ddev stop Stop the local environment'; + $this->io->writeln($lines); } /** diff --git a/tests/phpunit/src/Commands/App/SetupCommandTest.php b/tests/phpunit/src/Commands/App/SetupCommandTest.php index 9c4c78ca..94db8691 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/App/SetupCommandTest.php @@ -313,6 +313,8 @@ public function testSetupFreshNonInteractive(): void $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('ddev drush uli', $output); + $this->assertStringContainsString('git push', $output); + $this->assertStringContainsString('runs the master branch', $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'))); From 993a5903c39b09d64bd5757adaa893658c407594 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 02:42:26 +0300 Subject: [PATCH 08/11] Move the local-dev flow into a dev namespace: dev:init, dev:start, dev:stop setup becomes dev:init, and the daily start/stop loop gets thin acli wrappers so newcomers stay in one vocabulary for the lifecycle moments. dev:start re-prints the site URL and health-checks it; dev:stop shuts the stack down. Everything else (drush, logs, ssh) intentionally stays with ddev directly. The namespace is platform-neutral so future platforms become a routing decision inside dev:init rather than a new prefix. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- install.sh | 12 +-- .../DevInitCommand.php} | 65 ++--------- src/Command/Dev/DevStackTrait.php | 76 +++++++++++++ src/Command/Dev/DevStartCommand.php | 41 +++++++ src/Command/Dev/DevStopCommand.php | 41 +++++++ tests/phpunit/src/Application/KernelTest.php | 5 +- .../DevInitCommandTest.php} | 26 ++--- .../Commands/Dev/DevStartStopCommandTest.php | 102 ++++++++++++++++++ 9 files changed, 294 insertions(+), 76 deletions(-) rename src/Command/{App/SetupCommand.php => Dev/DevInitCommand.php} (87%) create mode 100644 src/Command/Dev/DevStackTrait.php create mode 100644 src/Command/Dev/DevStartCommand.php create mode 100644 src/Command/Dev/DevStopCommand.php rename tests/phpunit/src/Commands/{App/SetupCommandTest.php => Dev/DevInitCommandTest.php} (95%) create mode 100644 tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php diff --git a/README.md b/README.md index 4bcca876..8aa71290 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Go from nothing to a working local development environment with one command: 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 setup`, 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 setup`. See `acli help setup` for details, including non-interactive usage for CI. +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/ diff --git a/install.sh b/install.sh index 89f79350..5764771b 100755 --- a/install.sh +++ b/install.sh @@ -4,7 +4,7 @@ # # curl -fsSL https://raw.githubusercontent.com/acquia/cli/main/install.sh | sh # -# Installs the latest release of Acquia CLI and then starts `acli setup`, +# 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 @@ -14,7 +14,7 @@ # 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 setup` after install. +# 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 @@ -97,12 +97,12 @@ fi say "" # When piped to sh, stdin is the script itself. Reattach the terminal so -# `acli setup` can ask questions; in truly non-interactive contexts (CI), +# `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" setup + exec "$INSTALL_DIR/acli" dev:init elif [ -e /dev/tty ] && (: /dev/null; then - exec "$INSTALL_DIR/acli" setup 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 setup fails partway you can fix the problem and re-run acli setup to resume where it left off." - . "\n\nFor non-interactive use (CI, scripts), pass the environment ID and credentials: ACLI_KEY=... ACLI_SECRET=... acli setup myapp.dev --no-interaction. This requires an SSH key already registered with the Cloud Platform."); + . "\n\nEvery step is skipped automatically if it is already done, so if setup 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 @@ -91,11 +92,11 @@ private function checkPrerequisites(): void } } if ($missing) { - throw new AcquiaCliException("Some required tools are missing. Install them with the commands below, then re-run acli setup:\n" . implode("\n", $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 setup.'); + 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'); } @@ -261,19 +262,6 @@ private function ensureDdevConfigured(OutputInterface $output): void $this->checklist->completePreviousItem(); } - /** - * @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(); - } - /** * Install Composer dependencies inside the ddev web container so that * PHP and Composer are not required on the host. @@ -345,39 +333,6 @@ private function refreshDrupal(OutputInterface $output): void } } - 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`.'); - } - } - private function printSummary(EnvironmentResponse $environment, string $url): void { $this->io->success("Your local development environment is ready: $url"); @@ -395,7 +350,7 @@ private function printSummary(EnvironmentResponse $environment, string $url): vo $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[] = ' ddev stop Stop the local environment'; + $lines[] = ' acli dev:stop Stop the local environment (acli dev:start brings it back)'; $this->io->writeln($lines); } diff --git a/src/Command/Dev/DevStackTrait.php b/src/Command/Dev/DevStackTrait.php new file mode 100644 index 00000000..827a6d8c --- /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 + */ + protected 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(); + } + + protected 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. + */ + protected 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..a160ccec --- /dev/null +++ b/src/Command/Dev/DevStartCommand.php @@ -0,0 +1,41 @@ +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..08a71cf3 --- /dev/null +++ b/src/Command/Dev/DevStopCommand.php @@ -0,0 +1,41 @@ +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/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index 0bbaf17d..9ec6eded 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -43,7 +43,6 @@ private function getStart(): string docs Open Acquia product documentation in a web browser help Display help for a command list [self:list] List commands - setup Set up a complete local development environment for an Acquia application acsf acsf:list [acsf] List all Acquia Cloud Site Factory commands api @@ -72,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/App/SetupCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php similarity index 95% rename from tests/phpunit/src/Commands/App/SetupCommandTest.php rename to tests/phpunit/src/Commands/Dev/DevInitCommandTest.php index 94db8691..ec59a966 100644 --- a/tests/phpunit/src/Commands/App/SetupCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Acquia\Cli\Tests\Commands\App; +namespace Acquia\Cli\Tests\Commands\Dev; -use Acquia\Cli\Command\App\SetupCommand; use Acquia\Cli\Command\CommandBase; +use Acquia\Cli\Command\Dev\DevInitCommand; use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\Commands\Ide\IdeHelper; use Acquia\Cli\Tests\Commands\Pull\PullCommandTestBase; @@ -20,9 +20,9 @@ use Symfony\Component\Finder\SplFileInfo; /** - * @property \Acquia\Cli\Command\App\SetupCommand $command + * @property \Acquia\Cli\Command\Dev\DevInitCommand $command */ -class SetupCommandTest extends PullCommandTestBase +class DevInitCommandTest extends PullCommandTestBase { private static string $environmentId = '24-a47ac10b-58cc-4372-a567-0e02b2c3d470'; @@ -36,7 +36,7 @@ protected function createCommand(): CommandBase { $this->httpClientProphecy = $this->prophet->prophesize(Client::class); - return new SetupCommand( + return new DevInitCommand( $this->localMachineHelper, $this->datastoreCloud, $this->datastoreAcli, @@ -107,7 +107,7 @@ private function mockDdev(ObjectProphecy $localMachineHelper, string $dir, bool ->shouldBeCalled(); } - public function testSetupMissingPrerequisites(): void + public function testDevInitMissingPrerequisites(): void { $localMachineHelper = $this->mockLocalMachineHelper(); $localMachineHelper->commandExists('git')->willReturn(true); @@ -118,7 +118,7 @@ public function testSetupMissingPrerequisites(): void $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); } - public function testSetupDockerNotRunning(): void + public function testDevInitDockerNotRunning(): void { $localMachineHelper = $this->mockLocalMachineHelper(); foreach (['git', 'docker', 'ddev'] as $binary) { @@ -132,7 +132,7 @@ public function testSetupDockerNotRunning(): void $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); } - public function testSetupNotAuthenticatedNonInteractive(): void + public function testDevInitNotAuthenticatedNonInteractive(): void { $localMachineHelper = $this->mockLocalMachineHelper(); $this->mockPrerequisitesFound($localMachineHelper); @@ -142,7 +142,7 @@ public function testSetupNotAuthenticatedNonInteractive(): void $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL, false); } - public function testSetupNoSshKeyNonInteractive(): void + public function testDevInitNoSshKeyNonInteractive(): void { $localMachineHelper = $this->mockLocalMachineHelper(); $this->mockPrerequisitesFound($localMachineHelper); @@ -162,7 +162,7 @@ public function testSetupNoSshKeyNonInteractive(): void * Without --dir, setup confirms the clone directory interactively with a * derived default, and clones into whatever the user answers. */ - public function testSetupPromptsForCloneDirectory(): void + public function testDevInitPromptsForCloneDirectory(): void { $answeredDir = Path::join($this->projectDir, 'my-custom-dir'); $localMachineHelper = $this->mockLocalMachineHelper(); @@ -197,7 +197,7 @@ public function testSetupPromptsForCloneDirectory(): void * A failed clone must throw the clone error, not attempt the branch * checkout in a directory that does not exist. */ - public function testSetupCloneFailure(): void + public function testDevInitCloneFailure(): void { $dir = Path::join($this->projectDir, 'site'); $localMachineHelper = $this->mockLocalMachineHelper(); @@ -235,7 +235,7 @@ public function testSetupCloneFailure(): void * From nothing to a working site, non-interactively: clone, configure * ddev, start it, import the database, sync files. */ - public function testSetupFreshNonInteractive(): void + public function testDevInitFreshNonInteractive(): void { $dir = Path::join($this->projectDir, 'site'); $localMachineHelper = $this->mockLocalMachineHelper(); @@ -324,7 +324,7 @@ public function testSetupFreshNonInteractive(): void * Re-running setup on an existing checkout with an installed site skips * every completed step instead of redoing it. */ - public function testSetupResumeSkipsCompletedSteps(): void + public function testDevInitResumeSkipsCompletedSteps(): void { $dir = $this->projectDir; $localMachineHelper = $this->mockLocalMachineHelper(); diff --git a/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php b/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php new file mode 100644 index 00000000..781d077b --- /dev/null +++ b/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php @@ -0,0 +1,102 @@ +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', Argument::any()) + ->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('acli dev:stop', $this->getDisplay()); + } + + public function testDevStartWithoutProject(): void + { + $this->mockLocalMachineHelper(); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('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()); + } +} From 940103c5c0b0fcfd63d57a58350e9cdc5d86d633 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 17:23:29 +0300 Subject: [PATCH 09/11] Generate and upload the SSH key inside dev:init Instead of handing off to ssh-key:create-upload (three prompts and a required passphrase), dev:init now asks one question, generates an RSA-4096 key without a passphrase (the Cloud Platform API rejects ed25519), uploads it, and polls git access until the key is active. Users who want a passphrase-protected key are pointed at ssh-key:create-upload, which still works as before. Co-Authored-By: Claude Fable 5 --- src/Command/Dev/DevInitCommand.php | 50 +++++++++++++-- .../src/Commands/Dev/DevInitCommandTest.php | 61 +++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/Command/Dev/DevInitCommand.php b/src/Command/Dev/DevInitCommand.php index 2b342f42..11055b3f 100644 --- a/src/Command/Dev/DevInitCommand.php +++ b/src/Command/Dev/DevInitCommand.php @@ -6,6 +6,7 @@ use Acquia\Cli\Command\Pull\PullCommandBase; use Acquia\Cli\Exception\AcquiaCliException; +use Acquia\Cli\Helpers\LoopHelper; use Acquia\Cli\Helpers\SshCommandTrait; use AcquiaCloudApi\Endpoints\Account; use AcquiaCloudApi\Endpoints\SshKeys; @@ -45,7 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->checkPrerequisites(); $this->ensureAuthenticated($input, $output); $environment = $this->determineEnvironment($input, $output); - $this->ensureSshKey($input, $output); + $this->ensureSshKey($input, $environment); $this->dir = $this->determineTargetDirectory($input, $environment); $this->ensureCode($environment, $output); $this->linkApplication($environment); @@ -126,7 +127,7 @@ private function ensureAuthenticated(InputInterface $input, OutputInterface $out * * @throws \Acquia\Cli\Exception\AcquiaCliException */ - private function ensureSshKey(InputInterface $input, OutputInterface $output): void + private function ensureSshKey(InputInterface $input, EnvironmentResponse $environment): void { $cloudKeys = (new SshKeys($this->cloudApiClientService->getClient()))->getAll(); foreach ($this->findLocalSshKeys() as $localKey) { @@ -151,10 +152,49 @@ private function ensureSshKey(InputInterface $input, OutputInterface $output): v 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.'); - $exitCode = $this->getApplication()->find('ssh-key:create-upload')->run(new ArrayInput(['command' => 'ssh-key:create-upload']), $output); - if ($exitCode !== Command::SUCCESS) { - throw new AcquiaCliException('SSH key setup failed.'); + 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. + */ + private function waitForSshKeyInstallation(EnvironmentResponse $environment): void + { + $vcsUrl = $environment->vcs->url; + 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): 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']); + return $process->isSuccessful(); + }, function (): void { + $this->io->writeln('✓ SSH key is active'); + }); } /** diff --git a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php index ec59a966..3941700c 100644 --- a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -158,6 +158,67 @@ public function testDevInitNoSshKeyNonInteractive(): void ], [], 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'); + $localMachineHelper->readFile($keyPath . '.pub') + ->willReturn($sshKeysRequestBody['public_key']); + + // 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); + } + /** * Without --dir, setup confirms the clone directory interactively with a * derived default, and clones into whatever the user answers. From a15a9a946d937fb8618fb25697a7c59e688b7582 Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Thu, 6 Aug 2026 17:30:13 +0300 Subject: [PATCH 10/11] Update squizlabs/php_codesniffer to fix CVE-2026-67434 The advisory published 2026-08-05 fails composer audit --locked in CI for every branch. Lockfile-only dev-dependency bump within the 3.x pin. Co-Authored-By: Claude Fable 5 --- composer.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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", From fdeb595f11f8e5b34335209270a712e5fcfecb5d Mon Sep 17 00:00:00 2001 From: Lauri Timmanee Date: Fri, 7 Aug 2026 15:59:41 +0300 Subject: [PATCH 11/11] Address review feedback and make diff-lines mutation testing pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: - Use a template with mktemp for BSD/POSIX portability in install.sh. - Do not report the SSH key as active when the propagation wait times out: LoopHelper invokes its done callback on watchdog timeout, so track success explicitly and fail with a resumable message instead. - Fix stale 'setup' wording in docblocks and help text. Mutation testing (--min-covered-msi=100 on changed lines): - Read the docroot from ddev's config.yaml instead of guessing docroot/ vs web/ — more correct for any custom docroot. - Exempt console output and checklist calls in infection.json5, same rationale as the existing logger exemption; annotate declarative configure() methods and untestable spots with documented reasons. - New tests: reusing a previously generated key, non-matching agent keys in non-interactive mode, per-OS prerequisite remedies, the web/ docroot file sync path, URL fallback when ddev describe emits no usable JSON, unreachable-site warning, and drush-warning absence. - Make dev:* trait helpers private. Co-Authored-By: Claude Fable 5 --- infection.json5 | 9 +- install.sh | 2 +- src/Command/Dev/DevInitCommand.php | 48 ++++- src/Command/Dev/DevStackTrait.php | 8 +- src/Command/Dev/DevStartCommand.php | 1 + src/Command/Dev/DevStopCommand.php | 1 + src/Command/Pull/PullCommandBase.php | 3 + .../src/Commands/Dev/DevInitCommandTest.php | 174 ++++++++++++++++-- .../Commands/Dev/DevStartStopCommandTest.php | 78 +++++++- 9 files changed, 292 insertions(+), 32 deletions(-) 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 index 5764771b..85de5f57 100755 --- a/install.sh +++ b/install.sh @@ -37,7 +37,7 @@ case "$OS-$ARCH" in Linux-x86_64) ASSET="native-acli-linux-x86_64.tar.gz" ;; esac -TMP_DIR="$(mktemp -d)" +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 diff --git a/src/Command/Dev/DevInitCommand.php b/src/Command/Dev/DevInitCommand.php index 11055b3f..c86649e6 100644 --- a/src/Command/Dev/DevInitCommand.php +++ b/src/Command/Dev/DevInitCommand.php @@ -27,6 +27,7 @@ final class DevInitCommand extends PullCommandBase use DevStackTrait; use SshCommandTrait; + /** @infection-ignore-all */ protected function configure(): void { $this @@ -35,7 +36,7 @@ protected function configure(): void ->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 setup fails partway you can fix the problem and re-run acli dev:init to resume where it left off." + . "\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."); } @@ -185,20 +186,34 @@ private function generateAndUploadSshKey(EnvironmentResponse $environment): void /** * 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; - 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): bool { + // 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']); - return $process->isSuccessful(); - }, function (): void { - $this->io->writeln('✓ SSH key is active'); + $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 { @@ -215,6 +230,9 @@ private function findSshAgentKeys(): array /** * 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 { @@ -328,7 +346,7 @@ private function installComposerDependencies(OutputInterface $output): void /** * A fully bootstrappable Drupal site means the database was already - * imported; re-running setup should not clobber it. + * imported; re-running dev:init should not clobber it. */ private function siteIsInstalled(): bool { @@ -353,6 +371,9 @@ private function importDatabaseDumps(array $dumpPaths, OutputInterface $output): 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); } } @@ -360,6 +381,9 @@ private function importDatabaseDumps(array $dumpPaths, OutputInterface $output): /** * 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 { @@ -395,13 +419,17 @@ private function printSummary(EnvironmentResponse $environment, string $url): vo } /** - * ddev projects may use web/ as the docroot (e.g. Drupal CMS) instead of - * Acquia's traditional docroot/. + * 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 { - if (!is_dir(Path::join($this->dir, 'docroot')) && is_dir(Path::join($this->dir, 'web'))) { - return Path::join($this->dir, 'web', 'sites', $site, 'files'); + $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 index 827a6d8c..869fa793 100644 --- a/src/Command/Dev/DevStackTrait.php +++ b/src/Command/Dev/DevStackTrait.php @@ -19,7 +19,7 @@ trait DevStackTrait /** * @throws \Acquia\Cli\Exception\AcquiaCliException */ - protected function resolveProjectDir(InputInterface $input): string + private function resolveProjectDir(InputInterface $input): string { $dir = $input->getOption('dir') ? Path::makeAbsolute(Path::canonicalize($input->getOption('dir')), getcwd()) : getcwd(); if (!file_exists(Path::join($dir, '.ddev', 'config.yaml'))) { @@ -31,7 +31,7 @@ protected function resolveProjectDir(InputInterface $input): string /** * @throws \Acquia\Cli\Exception\AcquiaCliException */ - protected function startLocalEnvironment(OutputInterface $output): void + 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); @@ -41,7 +41,7 @@ protected function startLocalEnvironment(OutputInterface $output): void $this->checklist->completePreviousItem(); } - protected function getLocalSiteUrl(): string + private function getLocalSiteUrl(): string { $process = $this->localMachineHelper->execute(['ddev', 'describe', '-j'], null, $this->dir, false); if ($process->isSuccessful()) { @@ -58,7 +58,7 @@ protected function getLocalSiteUrl(): string * A warning, not a failure: some sites legitimately need extra local * steps, and everything else has already succeeded. */ - protected function checkSiteResponds(string $url): void + private function checkSiteResponds(string $url): void { try { $status = $this->httpClient->request('GET', $url, [ diff --git a/src/Command/Dev/DevStartCommand.php b/src/Command/Dev/DevStartCommand.php index a160ccec..c198e1b9 100644 --- a/src/Command/Dev/DevStartCommand.php +++ b/src/Command/Dev/DevStartCommand.php @@ -16,6 +16,7 @@ final class DevStartCommand extends PullCommandBase { use DevStackTrait; + /** @infection-ignore-all */ protected function configure(): void { $this diff --git a/src/Command/Dev/DevStopCommand.php b/src/Command/Dev/DevStopCommand.php index 08a71cf3..5718f013 100644 --- a/src/Command/Dev/DevStopCommand.php +++ b/src/Command/Dev/DevStopCommand.php @@ -17,6 +17,7 @@ final class DevStopCommand extends PullCommandBase { use DevStackTrait; + /** @infection-ignore-all */ protected function configure(): void { $this diff --git a/src/Command/Pull/PullCommandBase.php b/src/Command/Pull/PullCommandBase.php index f4efead7..bee46bf6 100644 --- a/src/Command/Pull/PullCommandBase.php +++ b/src/Command/Pull/PullCommandBase.php @@ -173,6 +173,9 @@ protected function pullDatabase(InputInterface $input, OutputInterface $output, } } + // Single-database callers cannot observe a slice of this list, and + // multi-database callers ignore the return value. + // @infection-ignore-all return $localFilepaths; } diff --git a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php index 3941700c..47b6bed8 100644 --- a/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevInitCommandTest.php @@ -85,13 +85,20 @@ private function mockLocalSshKey(ObjectProphecy $localMachineHelper, string $pub 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) - ->willReturn($process->reveal()); + ->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(); - $drushStatus = $this->mockProcess($siteInstalled); - $drushStatus->getOutput()->willReturn($siteInstalled ? 'Successful' : ''); + // 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(); @@ -110,12 +117,21 @@ private function mockDdev(ObjectProphecy $localMachineHelper, string $dir, bool public function testDevInitMissingPrerequisites(): void { $localMachineHelper = $this->mockLocalMachineHelper(); - $localMachineHelper->commandExists('git')->willReturn(true); + $localMachineHelper->commandExists('git')->willReturn(false); $localMachineHelper->commandExists('docker')->willReturn(false); $localMachineHelper->commandExists('ddev')->willReturn(false); - $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage('Some required tools are missing'); - $this->executeCommand([], [], OutputInterface::VERBOSITY_NORMAL); + $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 @@ -182,8 +198,9 @@ public function testDevInitGeneratesAndUploadsSshKey(): void ->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']); + ->willReturn($sshKeysRequestBody['public_key'] . "\n"); // Upload and installation polling. $this->mockRequest('postAccountSshKeys', null, [ @@ -220,7 +237,84 @@ public function testDevInitGeneratesAndUploadsSshKey(): void } /** - * Without --dir, setup confirms the clone directory interactively with a + * 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 @@ -299,9 +393,11 @@ public function testDevInitCloneFailure(): void 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); - $this->mockRequest('getAccount'); + $account = $this->mockRequest('getAccount'); $environment = $this->mockRequest('getEnvironment', self::$environmentId); $sshKeys = $this->mockRequest('getAccountSshKeys'); $this->mockLocalSshKey($localMachineHelper, $sshKeys[0]->public_key); @@ -349,7 +445,7 @@ public function testDevInitFreshNonInteractive(): void '-avPhze', 'ssh -o StrictHostKeyChecking=accept-new', $environment->ssh_url . ':/mnt/files/site.dev/sites/default/files/', - $dir . '/docroot/sites/default/files', + Path::join($dir, 'web', 'sites', 'default', 'files'), ], Argument::type('callable'), null, false) ->willReturn($process->reveal()) ->shouldBeCalled(); @@ -370,19 +466,21 @@ public function testDevInitFreshNonInteractive(): void $output = $this->getDisplay(); $this->assertSame(0, $this->getStatusCode()); $this->assertStringContainsString('✓ Found git, docker, and ddev', $output); - $this->assertStringContainsString('✓ Authenticated as', $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 setup on an existing checkout with an installed site skips + * Re-running dev:init on an existing checkout with an installed site skips * every completed step instead of redoing it. */ public function testDevInitResumeSkipsCompletedSteps(): void @@ -398,13 +496,17 @@ public function testDevInitResumeSkipsCompletedSteps(): void $localMachineHelper->isBrowserAvailable()->willReturn(false); // An existing checkout of this application with ddev already - // configured and composer dependencies already installed. + // 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); @@ -420,5 +522,49 @@ public function testDevInitResumeSkipsCompletedSteps(): void $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 index 781d077b..8179fc93 100644 --- a/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php +++ b/tests/phpunit/src/Commands/Dev/DevStartStopCommandTest.php @@ -61,7 +61,11 @@ public function testDevStart(): void ->shouldBeCalled(); $response = $this->prophet->prophesize(ResponseInterface::class); $response->getStatusCode()->willReturn(200); - $this->httpClientProphecy->request('GET', 'https://site.ddev.site', Argument::any()) + $this->httpClientProphecy->request('GET', 'https://site.ddev.site', [ + 'http_errors' => false, + 'timeout' => 30, + 'verify' => false, + ]) ->willReturn($response->reveal()) ->shouldBeCalled(); @@ -69,14 +73,84 @@ public function testDevStart(): void $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('Run this command from your project directory, or run `acli dev:init` to create one.'); + $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); }