From 90369d30c460c3dc20a75504daa59327f1d5a34b Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Fri, 20 Dec 2024 07:23:39 +1100 Subject: [PATCH] Moved installer utilities to traits and classes. Part 2. --- .vortex/installer/phpcs.xml | 4 +- .../installer/src/Command/InstallCommand.php | 225 +++++++++++------ .../installer/src/Traits/FilesystemTrait.php | 239 ++++++++++++++++++ .vortex/installer/src/Traits/TuiTrait.php | 51 +--- 4 files changed, 391 insertions(+), 128 deletions(-) create mode 100644 .vortex/installer/src/Traits/FilesystemTrait.php diff --git a/.vortex/installer/phpcs.xml b/.vortex/installer/phpcs.xml index 88d613c61..a46acf690 100644 --- a/.vortex/installer/phpcs.xml +++ b/.vortex/installer/phpcs.xml @@ -3,7 +3,9 @@ Custom PHPCS standard. - + + + diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php index 22a440a5c..ffb3ba10b 100644 --- a/.vortex/installer/src/Command/InstallCommand.php +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -8,6 +8,7 @@ use DrevOps\Installer\Converter; use DrevOps\Installer\File; use DrevOps\Installer\Traits\EnvTrait; +use DrevOps\Installer\Traits\FilesystemTrait; use DrevOps\Installer\Traits\GitTrait; use DrevOps\Installer\Traits\PrinterTrait; use DrevOps\Installer\Traits\PromptsTrait; @@ -15,7 +16,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Filesystem; /** * Run command. @@ -31,6 +34,7 @@ class InstallCommand extends Command { use PrinterTrait; use PromptsTrait; use TuiTrait; + use FilesystemTrait; /** * Defines installer status message flags. @@ -50,11 +54,6 @@ class InstallCommand extends Command { final const ANSWER_NO = 'n'; - /** - * Defines current working directory. - */ - protected static string $currentDir; - /** * Defines default command name. * @@ -67,14 +66,43 @@ class InstallCommand extends Command { */ protected Config $config; + /** + * Output interface. + */ + protected OutputInterface $output; + + /** + * Constructor. + * + * @param string|null $name + * File system. + * @param \Symfony\Component\Filesystem\Filesystem $fs + * Command name. + */ + public function __construct( + ?string $name = NULL, + ?Filesystem $fs = NULL, + ) { + parent::__construct($name); + $this->fs = is_null($fs) ? new Filesystem() : $fs; + } + /** * Configures the current command. */ protected function configure(): void { - $this - ->setName('Vortex CLI installer') - ->addArgument('path', InputArgument::OPTIONAL, 'Destination directory. Optional. Defaults to the current directory.') - ->setHelp($this->getHelpText()); + $this->setName('Vortex CLI installer'); + $this->setDescription('Install Vortex CLI from remote or local repository.'); + $this->setHelp(<<addArgument('path', InputArgument::OPTIONAL, 'Destination directory. Optional. Defaults to the current directory.'); + + $this->addOption('root', NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.'); $this->config = new Config(); } @@ -83,44 +111,134 @@ protected function configure(): void { * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { - $cwd = getcwd(); - if (!$cwd) { - throw new \RuntimeException('Unable to determine current working directory.'); - } - self::$currentDir = $cwd; + $this->output = $output; - static::initConfig($input); + try { + $this->checkRequirements(); - if ($this->config->get('help')) { - $output->write($this->getHelpText()); + $path = $input->getArgument('path'); + $this->resolveOptions($input->getOptions(), $path); - return 0; + $this->doExecute(); } + catch (\Exception $exception) { + $this->output->writeln([ + 'Processing failed with an error:', + '' . $exception->getMessage() . '', + ]); - $this->checkRequirements(); + return Command::FAILURE; + } - $this->printHeader(); + $this->printFooter(); - $this->collectAnswers(); + // $this->output->writeln('Deployment finished successfully.'); + return Command::SUCCESS; + } - if ($this->askShouldProceed()) { - $this->download(); + /** + * Instantiate configuration from CLI option and environment variables. + * + * Installer configuration is a set of internal installer script variables, + * read from the environment variables. These environment variables are not + * read directly in any operations of this installer script. Instead, these + * environment variables are accessible with $this->config->get(). + * + * For simplicity of naming, internal installer config variables used in + * $this->config->get() are matching environment variables names. + * + * @param array $options + * Array of CLI options. + * @param string|null $path + * Destination directory. Optional. Defaults to the current directory. + */ + protected function resolveOptions(array $options, ?string $path): void { + if (!empty($options['quiet'])) { + $this->config->set('quiet', TRUE); + } - $this->prepareDestination(); + if (!empty($options['no-ansi'])) { + $this->config->set('ANSI', FALSE); + } + else { + // On Windows, default to no ANSI, except in ANSICON and ConEmu. + // Everywhere else, default to ANSI if stdout is a terminal. + $is_ansi = (DIRECTORY_SEPARATOR === '\\') + ? (FALSE !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI')) + : (function_exists('posix_isatty') && posix_isatty(1)); + $this->config->set('ANSI', $is_ansi); + } - $this->replaceTokens(); + // Set root directory to use it for path resolution. + $this->fsSetRootDir(!empty($options['root']) && is_scalar($options['root']) ? strval($options['root']) : NULL); - $this->copyFiles(); + // Set destination directory. + if (!empty($path)) { + $path = $this->fsGetAbsolutePath($path); + if (!is_readable($path) || !is_dir($path)) { + throw new \RuntimeException(sprintf('Destination directory "%s" is not readable or does not exist.', $path)); + } + } + $this->config->set('VORTEX_INSTALL_DST_DIR', $path ?: static::getenvOrDefault('VORTEX_INSTALL_DST_DIR', $this->fsGetRootDir())); - $this->processDemo(); + // Load .env file from the destination directory, if it exists. + if ($this->fs->exists($this->getDstDir() . '/.env')) { + static::loadDotenv($this->getDstDir() . '/.env'); + } + + // Internal version of Vortex. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_VERSION', static::getenvOrDefault('VORTEX_VERSION', 'develop')); + // Flag to display install debug information. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_INSTALL_DEBUG', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEBUG', FALSE)); + // Flag to proceed with installation. If FALSE - the installation will only + // print resolved values and will not proceed. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_INSTALL_PROCEED', (bool) static::getenvOrDefault('VORTEX_INSTALL_PROCEED', TRUE)); + // Temporary directory to download and expand files to. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_INSTALL_TMP_DIR', static::getenvOrDefault('VORTEX_INSTALL_TMP_DIR', File::createTempdir())); + // Path to local Vortex repository. If not provided - remote will be used. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_INSTALL_LOCAL_REPO', static::getenvOrDefault('VORTEX_INSTALL_LOCAL_REPO')); + // Optional commit to download. If not provided, latest release will be + // downloaded. + // @todo Convert to option and remove from the environment variables. + $this->config->set('VORTEX_INSTALL_COMMIT', static::getenvOrDefault('VORTEX_INSTALL_COMMIT', 'HEAD')); - $this->printFooter(); + // Internal flag to enforce DEMO mode. If not set, the demo mode will be + // discovered automatically. + if (!is_null(static::getenvOrDefault('VORTEX_INSTALL_DEMO'))) { + $this->config->set('VORTEX_INSTALL_DEMO', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO')); } - else { + // Internal flag to skip processing of the demo mode. + $this->config->set('VORTEX_INSTALL_DEMO_SKIP', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO_SKIP', FALSE)); + } + + /** + * Execute the command. + */ + protected function doExecute(): void { + $this->printHeader(); + + $this->collectAnswers(); + + if (!$this->askShouldProceed()) { $this->printAbort(); } - return 0; + $this->download(); + + $this->prepareDestination(); + + $this->replaceTokens(); + + $this->copyFiles(); + + $this->processDemo(); + + $this->printFooter(); } protected function prepareDestination(): void { @@ -466,42 +584,6 @@ protected function collectAnswers(): void { } } - /** - * Instantiate installer configuration from environment variables. - * - * Installer configuration is a set of internal installer script variables, - * read from the environment variables. These environment variables are not - * read directly in any operations of this installer script. Instead, these - * environment variables are accessible with get_installer_config(). - * - * For simplicity of naming, internal installer config variables are matching - * environment variables names. - */ - protected function initInstallerConfig(): void { - // Internal version of Vortex. - $this->config->set('VORTEX_VERSION', static::getenvOrDefault('VORTEX_VERSION', 'develop')); - // Flag to display install debug information. - $this->config->set('VORTEX_INSTALL_DEBUG', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEBUG', FALSE)); - // Flag to proceed with installation. If FALSE - the installation will only - // print resolved values and will not proceed. - $this->config->set('VORTEX_INSTALL_PROCEED', (bool) static::getenvOrDefault('VORTEX_INSTALL_PROCEED', TRUE)); - // Temporary directory to download and expand files to. - $this->config->set('VORTEX_INSTALL_TMP_DIR', static::getenvOrDefault('VORTEX_INSTALL_TMP_DIR', File::createTempdir())); - // Path to local Vortex repository. If not provided - remote will be used. - $this->config->set('VORTEX_INSTALL_LOCAL_REPO', static::getenvOrDefault('VORTEX_INSTALL_LOCAL_REPO')); - // Optional commit to download. If not provided, latest release will be - // downloaded. - $this->config->set('VORTEX_INSTALL_COMMIT', static::getenvOrDefault('VORTEX_INSTALL_COMMIT', 'HEAD')); - - // Internal flag to enforce DEMO mode. If not set, the demo mode will be - // discovered automatically. - if (!is_null(static::getenvOrDefault('VORTEX_INSTALL_DEMO'))) { - $this->config->set('VORTEX_INSTALL_DEMO', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO')); - } - // Internal flag to skip processing of the demo mode. - $this->config->set('VORTEX_INSTALL_DEMO_SKIP', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO_SKIP', FALSE)); - } - protected function getDstDir(): ?string { return $this->config->get('VORTEX_INSTALL_DST_DIR'); } @@ -545,15 +627,4 @@ protected function executeCallback(string $prefix, string $name): mixed { return NULL; } - /** - * Init all config. - */ - public function initConfig(InputInterface $input): void { - $this->initCliArgsAndOptions($input); - - static::loadDotenv($this->getDstDir() . '/.env'); - - $this->initInstallerConfig(); - } - } diff --git a/.vortex/installer/src/Traits/FilesystemTrait.php b/.vortex/installer/src/Traits/FilesystemTrait.php new file mode 100644 index 000000000..0d5d74ca7 --- /dev/null +++ b/.vortex/installer/src/Traits/FilesystemTrait.php @@ -0,0 +1,239 @@ + + */ + protected array $fsOriginalCwdStack = []; + + /** + * Set root directory path. + * + * @param string|null $path + * The path of the root directory. + * + * @return static + * The called object. + */ + protected function fsSetRootDir(?string $path = NULL): static { + $path = empty($path) ? $this->fsGetRootDir() : $this->fsGetAbsolutePath($path); + $this->fsAssertPathsExist($path); + $this->fsRootDir = $path; + + return $this; + } + + /** + * Get root directory. + * + * @return string + * Get value of the root directory, the directory where the + * script was started from or current working directory. + */ + protected function fsGetRootDir(): string { + if (isset($this->fsRootDir)) { + return $this->fsRootDir; + } + + if (isset($_SERVER['PWD'])) { + return $_SERVER['PWD']; + } + + return (string) getcwd(); + } + + /** + * Set current working directory. + * + * It is important to note that this should be called in pair with + * cwdRestore(). + * + * @param string $dir + * Path to the current directory. + * + * @return static + * The called object. + */ + protected function fsSetCwd(string $dir): static { + chdir($dir); + $this->fsOriginalCwdStack[] = $dir; + + return $this; + } + + /** + * Set current working directory to a previously saved path. + * + * It is important to note that this should be called in pair with cwdSet(). + */ + protected function fsCwdRestore(): void { + $dir = array_shift($this->fsOriginalCwdStack); + if ($dir) { + chdir($dir); + } + } + + /** + * Get current working directory. + * + * @return string + * Full path of current working directory. + */ + protected function fsCwdGet(): string { + return (string) getcwd(); + } + + /** + * Get absolute path for provided file. + * + * @param string $file + * File to resolve. If absolute, no resolution will be performed. + * @param string|null $root + * Optional path to root dir. If not provided, internal root path is used. + * + * @return string + * Absolute path for provided file. + */ + protected function fsGetAbsolutePath(string $file, ?string $root = NULL): string { + if ($this->fs->isAbsolutePath($file)) { + return $this->fsRealpath($file); + } + + $root = $root ? $root : $this->fsGetRootDir(); + $root = $this->fsRealpath($root); + $file = $root . DIRECTORY_SEPARATOR . $file; + + return $this->fsRealpath($file); + } + + /** + * Check that path exists. + * + * @param string|array $paths + * File name or array of file names to check. + * @param bool $strict + * If TRUE and the file does not exist, an exception will be thrown. + * Defaults to TRUE. + * + * @return bool + * TRUE if file exists and FALSE if not, but only if $strict is FALSE. + * + * @throws \Exception + * If at least one file does not exist. + */ + protected function fsAssertPathsExist($paths, bool $strict = TRUE): bool { + $paths = is_array($paths) ? $paths : [$paths]; + + if (!$this->fs->exists($paths)) { + if ($strict) { + throw new \Exception(sprintf('One of the files or directories does not exist: %s', implode(', ', $paths))); + } + + return FALSE; + } + + return TRUE; + } + + /** + * Replacement for PHP's `realpath` resolves non-existing paths. + * + * The main deference is that it does not return FALSE on non-existing + * paths. + * + * @param string $path + * Path that needs to be resolved. + * + * @return string + * Resolved path. + * + * @see https://stackoverflow.com/a/29372360/712666 + * + * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.NPathComplexity) + */ + protected function fsRealpath(string $path): string { + // Whether $path is unix or not. + $unipath = $path === '' || $path[0] !== '/'; + $unc = str_starts_with($path, '\\\\'); + + // Attempt to detect if path is relative in which case, add cwd. + if (!str_contains($path, ':') && $unipath && !$unc) { + $path = getcwd() . DIRECTORY_SEPARATOR . $path; + if ($path[0] === '/') { + $unipath = FALSE; + } + } + + // Resolve path parts (single dot, double dot and double delimiters). + $path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path); + $parts = array_filter(explode(DIRECTORY_SEPARATOR, $path), static function ($part): bool { + return strlen($part) > 0; + }); + + $absolutes = []; + foreach ($parts as $part) { + if ('.' === $part) { + continue; + } + if ('..' === $part) { + array_pop($absolutes); + } + else { + $absolutes[] = $part; + } + } + + $path = implode(DIRECTORY_SEPARATOR, $absolutes); + + // Resolve any symlinks. + if (function_exists('readlink') && file_exists($path) && linkinfo($path) > 0) { + $path = readlink($path); + + if (!$path) { + throw new \Exception(sprintf('Could not resolve symlink for path: %s', $path)); + } + } + + // Put initial separator that could have been lost. + $path = $unipath ? $path : '/' . $path; + + $path = $unc ? '\\\\' . $path : $path; + + if (str_starts_with($path, sys_get_temp_dir())) { + $tmp_realpath = realpath(sys_get_temp_dir()); + if ($tmp_realpath) { + $path = str_replace(sys_get_temp_dir(), $tmp_realpath, $path); + } + } + + return $path; + } + +} diff --git a/.vortex/installer/src/Traits/TuiTrait.php b/.vortex/installer/src/Traits/TuiTrait.php index 7d91caacc..2bfc693ae 100644 --- a/.vortex/installer/src/Traits/TuiTrait.php +++ b/.vortex/installer/src/Traits/TuiTrait.php @@ -4,8 +4,6 @@ namespace DrevOps\Installer\Traits; -use Symfony\Component\Console\Input\InputInterface; - /** * TUI trait. */ @@ -51,18 +49,6 @@ protected function closeStdinHandle(): void { fclose($_stdin_handle); } - /** - * Print help. - */ - protected function getHelpText(): string { - return <<isQuiet()) { $this->printHeaderQuiet(); @@ -123,7 +109,7 @@ protected function printHeaderQuiet(): void { } protected function printSummary(): void { - $values['Current directory'] = self::$currentDir; + $values['Current directory'] = $this->fsGetRootDir(); $values['Destination directory'] = $this->getDstDir(); $values['Vortex version'] = $this->config->get('VORTEX_VERSION'); $values['Vortex commit'] = $this->formatNotEmpty($this->config->get('VORTEX_INSTALL_COMMIT'), 'Latest'); @@ -264,41 +250,6 @@ protected function getAnswers(): array { return $_answers; } - /** - * Initialise CLI options. - */ - protected function initCliArgsAndOptions(InputInterface $input): void { - $arg = $input->getArguments(); - $options = $input->getOptions(); - - if (!empty($options['help'])) { - $this->config->set('help', TRUE); - } - - if (!empty($options['quiet'])) { - $this->config->set('quiet', TRUE); - } - - if (!empty($options['no-ansi'])) { - $this->config->set('ANSI', FALSE); - } - else { - // On Windows, default to no ANSI, except in ANSICON and ConEmu. - // Everywhere else, default to ANSI if stdout is a terminal. - $is_ansi = (DIRECTORY_SEPARATOR === '\\') - ? (FALSE !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI')) - : (function_exists('posix_isatty') && posix_isatty(1)); - $this->config->set('ANSI', $is_ansi); - } - - if (!empty($arg['path'])) { - $this->config->set('VORTEX_INSTALL_DST_DIR', $arg['path']); - } - else { - $this->config->set('VORTEX_INSTALL_DST_DIR', static::getenvOrDefault('VORTEX_INSTALL_DST_DIR', self::$currentDir)); - } - } - protected function askShouldProceed(): bool { $proceed = self::ANSWER_YES;