diff --git a/bin/dq-init b/bin/dq-init index 00faf20..0882d56 100644 --- a/bin/dq-init +++ b/bin/dq-init @@ -4,40 +4,59 @@ /** * dq-init: Step 1 of the drupalquick workflow. * - * Full workflow: - * 1. composer exec dq-init [--interactive] [--ddev] ← this script + * Full workflow (DDEV-first — prefix each step with `ddev`): + * 1. ddev composer exec dq-init [--interactive] [--no-ddev] ← this script * Delivers config.dq.yml to the project root. - * 2. composer exec dq-install + * 2. ddev composer exec dq-install * Installs any drupalquick-managed recipe packages via Composer. - * 3. drush dq:scaffold + * 3. ddev drush dq:scaffold * Installs Drupal, generates the theme, applies recipes, builds assets. * * The sole output of this script is config.dq.yml — a single YAML file the * developer reviews and owns before anything is installed. No Drupal code is * touched here; dq:scaffold is what actually builds the site. * - * Without flags — copies the template and exits. The user edits the file - * then runs `composer exec dq-install`. + * DDEV is the default context: dq-init also drops the DDEV local config into + * .ddev/ (config.local.yaml + the deploy-credentials example). Running on the + * host inside a DDEV project is refused (see Comingling) — use `ddev`. + * + * Without flags — copies the template + DDEV local config and exits. The user + * edits config.dq.yml then runs `ddev composer exec dq-install`. * * With --interactive — walks the user through site, theme, and recipe * choices via CLI prompts and writes a pre-filled config.dq.yml. * - * With --ddev — copies the drupalquick DDEV local config template into the - * project's .ddev/ directory if one exists. Can be combined with either of - * the above modes. + * With --no-ddev — bare-host setup: skips the DDEV local config (you supply + * your own PHP/Node/database/webserver). Combines with the modes above. */ $packageRoot = dirname(__DIR__); $projectRoot = getcwd(); $destination = $projectRoot . '/config.dq.yml'; $interactive = in_array('--interactive', $argv, true); -$ddev = in_array('--ddev', $argv, true); +// DDEV is Quick's default install context; --no-ddev opts into a bare-host +// setup (you then supply your own PHP/Node/DB/webserver). --ddev is still +// accepted as an explicit no-op for clarity. +$ddev = !in_array('--no-ddev', $argv, true); -// Preset discovery is a plain class in this package — required directly, so -// the script keeps working without any Composer autoloader. +// Plain classes, required directly so the script works without a Composer +// autoloader. require_once $packageRoot . '/src/Config/PresetDiscovery.php'; +require_once $packageRoot . '/src/Environment/Comingling.php'; use DrupalQuick\Config\PresetDiscovery; +use DrupalQuick\Environment\Comingling; + +// Refuse a host run inside a DDEV project — mixing host and container tooling +// diverges composer/npm state and the host can't reach DDEV's database. +if ($guard = Comingling::hostInDdevProjectError($projectRoot, Comingling::env())) { + fwrite(STDERR, "❌ {$guard}\n"); + exit(1); +} + +// Next-step guidance is copy-paste correct for the project's mode: prefix with +// `ddev` when this is a DDEV project, bare otherwise. +$cmd = is_file($projectRoot . '/.ddev/config.yaml') ? 'ddev ' : ''; // ------------------------------------------------------------------ Helpers @@ -88,7 +107,7 @@ function chooseMany(string $question, array $options): array { if (!$interactive) { if (file_exists($destination)) { echo "ℹ️ config.dq.yml already exists. Edit it, then run:\n"; - echo " composer exec dq-install\n"; + echo " {$cmd}composer exec dq-install\n"; } else { $source = $packageRoot . '/templates/config.dq.yml'; if (!copy($source, $destination)) { @@ -97,7 +116,7 @@ if (!$interactive) { } echo "✅ config.dq.yml created in project root.\n"; echo " Edit it to configure your site, then run:\n"; - echo " composer exec dq-install\n"; + echo " {$cmd}composer exec dq-install\n"; } // Handle --ddev regardless of whether config already existed. @@ -119,7 +138,7 @@ if (file_exists($destination)) { $overwrite = strtolower(ask('config.dq.yml already exists. Overwrite it? (y/N)', 'n')); if ($overwrite !== 'y' && $overwrite !== 'yes') { echo "ℹ️ Keeping the existing config.dq.yml. Edit it, then run:\n"; - echo " composer exec dq-install\n"; + echo " {$cmd}composer exec dq-install\n"; if ($ddev) { ddev_copy($packageRoot, $projectRoot); } @@ -270,7 +289,7 @@ if (file_put_contents($destination, $yaml) === false) { echo "\n✅ config.dq.yml created.\n"; echo " Review it, make any edits, then run:\n"; -echo " composer exec dq-install\n"; +echo " {$cmd}composer exec dq-install\n"; if ($ddev) { ddev_copy($packageRoot, $projectRoot); @@ -286,7 +305,8 @@ function ddev_copy(string $packageRoot, string $projectRoot): void { $src = $packageRoot . '/templates/ddev/config.local.yaml'; if (!is_dir($ddevDir)) { - echo "ℹ️ --ddev passed but no .ddev directory found in project root. Skipping.\n"; + echo "ℹ️ No .ddev directory found. Quick is DDEV-first — run `ddev config`\n"; + echo " to set up DDEV, then re-run. (For a bare-host setup, pass --no-ddev.)\n"; return; } diff --git a/bin/dq-install b/bin/dq-install index 8e7b6ca..59f6f15 100644 --- a/bin/dq-install +++ b/bin/dq-install @@ -7,7 +7,7 @@ * Full workflow: * 1. composer exec dq-init — creates config.dq.yml * 2. composer exec dq-install — THIS SCRIPT - * 3. drush dq:scaffold — installs Drupal and builds the site + * 3. ddev drush dq:scaffold — installs Drupal and builds the site * * Reads config.dq.yml and resolves each recipe against the registry * (templates/recipe-registry.json). Registry recipes are standalone Composer @@ -44,13 +44,24 @@ require_once $autoload; // so they resolve regardless of the consumer autoloader's state. require_once $packageRoot . '/src/Config/RecipeOptions.php'; require_once $packageRoot . '/src/Config/RecipeBlocks.php'; +require_once $packageRoot . '/src/Environment/Comingling.php'; use DrupalQuick\Config\RecipeOptions; use DrupalQuick\Config\RecipeBlocks; +use DrupalQuick\Environment\Comingling; use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Process\Process; +// Refuse a host run inside a DDEV project — run `ddev composer exec dq-install`. +if ($guard = Comingling::hostInDdevProjectError($projectRoot, Comingling::env())) { + fwrite(STDERR, "❌ {$guard}\n"); + exit(1); +} + +// Next-step guidance prefixed with `ddev` in a DDEV project (bare otherwise). +$cmd = is_file($projectRoot . '/.ddev/config.yaml') ? 'ddev ' : ''; + /** * Runs an external command via Symfony Process, streaming its output. * @@ -86,7 +97,7 @@ try { $recipes = $config['recipes'] ?? []; if (empty($recipes)) { echo "ℹ️ No recipes listed in config.dq.yml. Nothing to install.\n"; - echo " Run `drush dq:scaffold` when ready.\n"; + echo " Run `{$cmd}drush dq:scaffold` when ready.\n"; exit(0); } @@ -200,7 +211,7 @@ if (!empty($toRequire)) { if (empty($toRequire)) { echo "ℹ️ No drupalquick-managed recipes to install (core/contrib recipes\n"; echo " are already available on disk).\n"; - echo " Run `drush dq:scaffold` when ready.\n"; + echo " Run `{$cmd}drush dq:scaffold` when ready.\n"; exit(0); } @@ -329,4 +340,4 @@ if ($needsOptionWrite || $needsBlockWrite) { } } -echo "\n Run `drush dq:scaffold` to build your site.\n"; +echo "\n Run `{$cmd}drush dq:scaffold` to build your site.\n"; diff --git a/src/Drush/Commands/CleanupCommand.php b/src/Drush/Commands/CleanupCommand.php index 71574e8..6059895 100644 --- a/src/Drush/Commands/CleanupCommand.php +++ b/src/Drush/Commands/CleanupCommand.php @@ -37,6 +37,9 @@ protected function configure(): void { protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new DrushStyle($input, $output); + if (!$this->guardDdevEnvironment()) { + return self::FAILURE; + } $purge = $input->getOption('purge') || $input->getOption('remove-everything'); if (!$input->getOption('force')) { diff --git a/src/Drush/Commands/DeployCommand.php b/src/Drush/Commands/DeployCommand.php index 55f7886..56bdab7 100644 --- a/src/Drush/Commands/DeployCommand.php +++ b/src/Drush/Commands/DeployCommand.php @@ -40,6 +40,9 @@ protected function configure(): void { protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new DrushStyle($input, $output); + if (!$this->guardDdevEnvironment()) { + return self::FAILURE; + } $self = Drush::aliasManager()->getSelf(); // 1. Resolve the target (flag overrides persisted/config setting). diff --git a/src/Drush/Commands/DrupalQuickHelpers.php b/src/Drush/Commands/DrupalQuickHelpers.php index 40afd6a..a21cb19 100644 --- a/src/Drush/Commands/DrupalQuickHelpers.php +++ b/src/Drush/Commands/DrupalQuickHelpers.php @@ -4,6 +4,7 @@ use Drupal\Component\Serialization\Json; use Drupal\Component\Serialization\Yaml; +use DrupalQuick\Environment\Comingling; use Drush\Drush; use Drush\Style\DrushStyle; use Symfony\Component\Process\Process; @@ -22,6 +23,22 @@ trait DrupalQuickHelpers { */ protected DrushStyle $io; + /** + * Refuses to run on the host inside a DDEV project (see Comingling). + * + * Call at the top of execute(), after $this->io is set: + * if (!$this->guardDdevEnvironment()) { return self::FAILURE; } + * Returns FALSE (with an error printed) when the command should abort. + */ + protected function guardDdevEnvironment(): bool { + $msg = Comingling::hostInDdevProjectError(getcwd(), Comingling::env()); + if ($msg !== NULL) { + $this->io->error($msg); + return FALSE; + } + return TRUE; + } + /** * Returns the Drupal web root (the docroot, e.g. the project's web/ dir). * diff --git a/src/Drush/Commands/ScaffoldCommand.php b/src/Drush/Commands/ScaffoldCommand.php index 7de82b0..875ca4c 100644 --- a/src/Drush/Commands/ScaffoldCommand.php +++ b/src/Drush/Commands/ScaffoldCommand.php @@ -42,12 +42,17 @@ protected function configure(): void { $this ->addOption('interactive', NULL, InputOption::VALUE_NONE, 'Prompt the user to fill out or override config values interactively.') ->addOption('force', NULL, InputOption::VALUE_NONE, 'Scaffold even when Drupal is already installed. This reinstalls the site and DESTROYS the existing database.') + ->addOption('theme-dev', NULL, InputOption::VALUE_NEGATABLE, 'Enable Twig development mode (twig debug + auto-reload, caches off) via `drush theme:dev` after the build, for live theme iteration. Defaults to on inside a DDEV web container; --no-theme-dev opts out. Reverse any time with `drush theme:dev off`.', NULL) ->addUsage('dq:scaffold') - ->addUsage('dq:scaffold --interactive'); + ->addUsage('dq:scaffold --interactive') + ->addUsage('dq:scaffold --no-theme-dev'); } protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new DrushStyle($input, $output); + if (!$this->guardDdevEnvironment()) { + return self::FAILURE; + } $configFile = getcwd() . '/config.dq.yml'; if (!file_exists($configFile)) { @@ -103,11 +108,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->writeln("✅ Configuration updated for this session.\n"); } + // Resolve theme-dev: explicit flag wins; otherwise default on inside a + // DDEV web container (the local-iteration context), like dq:static's + // --ddev-preview. Applied at the end of a successful build. + $themeDevOpt = $input->getOption('theme-dev'); + $enableThemeDev = $themeDevOpt ?? (getenv('IS_DDEV_PROJECT') === 'true'); + // Run the build phases inside one try/catch: every Drush call below uses // mustRun(), so any failure aborts with a clear "site may be partially // built" message instead of a raw stack trace. try { - return $this->runBuild($config, $registry); + return $this->runBuild($config, $registry, $enableThemeDev); } catch (\Throwable $e) { $this->io->error('Scaffold failed: ' . $e->getMessage()); @@ -119,7 +130,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Runs the build phases: install → theme → recipes → config → assets. */ - private function runBuild(array $config, array $registry): int { + private function runBuild(array $config, array $registry, bool $enableThemeDev = FALSE): int { $siteName = $config['site']['name'] ?? 'Drupal Site'; $accountName = $config['site']['admin_user'] ?? 'admin'; // Secure default: when no admin_pass is configured, generate a strong one @@ -500,6 +511,24 @@ private function runBuild(array $config, array $registry): int { $this->io->writeln('🎉 [drupalquick] Scaffold complete. Rebuilding caches...'); Drush::drush(Drush::aliasManager()->getSelf(), 'cache:rebuild')->mustRun(); + // Enable Twig development mode for live theme iteration (twig debug + + // auto-reload, render/page/dynamic caches off). This is Drupal's own + // development-settings mechanism (a key-value in the DB, not a file), so it + // survives rebuilds, is never committed or deployed, and reverses with + // `drush theme:dev off`. dq:static turns it off for the export so no debug + // comments leak into the static HTML. + if ($enableThemeDev) { + $this->io->writeln('🧑‍🎨 [drupalquick] Enabling Twig development mode (drush theme:dev on)...'); + $devProc = Drush::drush(Drush::aliasManager()->getSelf(), 'theme:dev', ['on'], ['yes' => TRUE]); + $devProc->run(); + if ($devProc->isSuccessful()) { + $this->io->writeln(' Twig debug + auto-reload on, caches off. Turn off with `drush theme:dev off`.'); + } + else { + $this->io->warning('Could not enable Twig development mode (drush theme:dev on). Needs Drush 13.6+. Skipped; the build is otherwise complete.'); + } + } + return self::SUCCESS; } diff --git a/src/Drush/Commands/StaticExportCommand.php b/src/Drush/Commands/StaticExportCommand.php index d6a6671..0200e57 100644 --- a/src/Drush/Commands/StaticExportCommand.php +++ b/src/Drush/Commands/StaticExportCommand.php @@ -41,6 +41,9 @@ protected function configure(): void { protected function execute(InputInterface $input, OutputInterface $output): int { $this->io = new DrushStyle($input, $output); + if (!$this->guardDdevEnvironment()) { + return self::FAILURE; + } $self = Drush::aliasManager()->getSelf(); // 1. Resolve settings (persisted config wins; fall back to config.dq.yml). @@ -82,53 +85,82 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - // 5. Clear Tome's static cache before every export. Tome's cache is - // content-keyed, not target-URI-keyed: a page rendered once under the - // site's live authoring host (e.g. a DDEV domain) is served from that - // cache as-is on later runs even after static.uri is set or changed, - // since nothing about the page's own content changed — leaking the - // authoring host into canonical links, RSS, and JSON-LD indefinitely. - // dq:static exists to represent the site's current state, so a full - // fresh render on every run is the correct default over a faster but - // possibly-stale incremental one (site sizes Quick targets make this - // cheap; path-count=1 below already trades some speed for correctness). - Drush::drush($self, 'php:eval', ["\\Drupal::cache('tome_static')->deleteAll();"])->mustRun(); - - // 6. Run the static export. One path per worker process: Drupal - // memoizes per-request state in long-lived services (menu.active_trail - // caches its route lookup for the life of the process), so Tome's - // default of several paths per process bakes the first page's active - // menu trail into every later page in the chunk. Fresh process per - // path keeps the server-rendered markup truthful; parallelism across - // processes (--process-count) still applies. - $this->io->writeln('🧊 [drupalquick] Generating static site with Tome...'); - $opts = ['yes' => TRUE, 'path-count' => 1]; - if ($uri) { - $opts['uri'] = $uri; + // 4.5. If Twig development mode is on (dq:scaffold enables it for local + // theme iteration), turn it off for the export: with twig debug on, Drupal + // bakes `` / file-name-suggestion comments into every + // rendered page, which would leak template paths into the static HTML. + // Toggling it off restores the production render; it's restored afterward + // so the developer's session continues. It's a DB key-value, so reading and + // flipping it is cheap and leaves no file trace. + $probe = Drush::drush($self, 'php:eval', ["echo \\Drupal::keyValue('development_settings')->get('twig_debug') ? '1' : '';"]); + $probe->run(); + $wasThemeDev = $probe->isSuccessful() && trim((string) $probe->getOutput()) === '1'; + if ($wasThemeDev) { + $this->io->writeln('🧑‍🎨 [drupalquick] Twig development mode is on — disabling it for a clean export (restored afterward).'); + Drush::drush($self, 'theme:dev', ['off'], ['yes' => TRUE])->mustRun(); } - Drush::drush($self, 'tome:static', [], $opts)->mustRun(); - $dir = $this->staticDirectory($self); + // The export runs inside try/finally so that if we disabled Twig dev mode + // above, it is ALWAYS restored — even when tome:static (a mustRun) throws + // partway. Without this a failed export would silently strand the + // developer's site in production mode. + try { + // 5. Clear Tome's static cache before every export. Tome's cache is + // content-keyed, not target-URI-keyed: a page rendered once under the + // site's live authoring host (e.g. a DDEV domain) is served from that + // cache as-is on later runs even after static.uri is set or changed, + // since nothing about the page's own content changed — leaking the + // authoring host into canonical links, RSS, and JSON-LD indefinitely. + // dq:static exists to represent the site's current state, so a full + // fresh render on every run is the correct default over a faster but + // possibly-stale incremental one (site sizes Quick targets make this + // cheap; path-count=1 below already trades some speed for correctness). + Drush::drush($self, 'php:eval', ["\\Drupal::cache('tome_static')->deleteAll();"])->mustRun(); - // 7. Belt-and-suspenders: rewrite any stray reference to the live - // authoring host into the configured URI across the exported files. - // Guards against the wrong host leaking through by any mechanism, not - // just the cache behavior above — cheap for a static site this size. - if ($uri) { - $this->rewriteExportHost($dir, $self, $uri); - } + // 6. Run the static export. One path per worker process: Drupal + // memoizes per-request state in long-lived services (menu.active_trail + // caches its route lookup for the life of the process), so Tome's + // default of several paths per process bakes the first page's active + // menu trail into every later page in the chunk. Fresh process per + // path keeps the server-rendered markup truthful; parallelism across + // processes (--process-count) still applies. + $this->io->writeln('🧊 [drupalquick] Generating static site with Tome...'); + $opts = ['yes' => TRUE, 'path-count' => 1]; + if ($uri) { + $opts['uri'] = $uri; + } + Drush::drush($self, 'tome:static', [], $opts)->mustRun(); - // 8. Emit a sibling .html beside every /index.html. The - // export's internal links are slashless (Drupal path form, matching the - // canonical tags), but static hosts 301 a slashless URL to its - // trailing-slash directory form — and that redirect discards the old - // page's view-transition snapshot, turning the page crossfade into a - // white flash. Netlify and GitHub Pages both resolve extensionless URLs - // to .html files *before* their directory handling, so the sibling makes - // slashless URLs serve directly (200, no redirect). The DDEV preview's - // nginx handles the same via try_files. Runs after the host rewrite so - // siblings copy the corrected markup. - $this->emitSlashlessSiblings($dir); + $dir = $this->staticDirectory($self); + + // 7. Belt-and-suspenders: rewrite any stray reference to the live + // authoring host into the configured URI across the exported files. + // Guards against the wrong host leaking through by any mechanism, not + // just the cache behavior above — cheap for a static site this size. + if ($uri) { + $this->rewriteExportHost($dir, $self, $uri); + } + + // 8. Emit a sibling .html beside every /index.html. The + // export's internal links are slashless (Drupal path form, matching the + // canonical tags), but static hosts 301 a slashless URL to its + // trailing-slash directory form — and that redirect discards the old + // page's view-transition snapshot, turning the page crossfade into a + // white flash. Netlify and GitHub Pages both resolve extensionless URLs + // to .html files *before* their directory handling, so the sibling makes + // slashless URLs serve directly (200, no redirect). The DDEV preview's + // nginx handles the same via try_files. Runs after the host rewrite so + // siblings copy the corrected markup. + $this->emitSlashlessSiblings($dir); + } + finally { + // Restore Twig development mode if the export turned it off, so the + // developer's local session picks up where it left off. + if ($wasThemeDev) { + Drush::drush($self, 'theme:dev', ['on'], ['yes' => TRUE])->run(); + $this->io->writeln('🧑‍🎨 [drupalquick] Restored Twig development mode (drush theme:dev off to disable).'); + } + } // @todo Before launch: emit a _redirects file into the export from a // static.redirects map in config.dq.yml (persisted to drupalquick.static diff --git a/src/Environment/Comingling.php b/src/Environment/Comingling.php new file mode 100644 index 0000000..c73299c --- /dev/null +++ b/src/Environment/Comingling.php @@ -0,0 +1,63 @@ + (string) getenv('IS_DDEV_PROJECT'), + 'DQ_ALLOW_HOST' => (string) getenv('DQ_ALLOW_HOST'), + ]; + } + +} diff --git a/tests/Unit/Environment/CominglingTest.php b/tests/Unit/Environment/CominglingTest.php new file mode 100644 index 0000000..d94b38c --- /dev/null +++ b/tests/Unit/Environment/CominglingTest.php @@ -0,0 +1,51 @@ +ddevProject = sys_get_temp_dir() . '/dq-comingle-ddev-' . uniqid(); + $this->bareProject = sys_get_temp_dir() . '/dq-comingle-bare-' . uniqid(); + mkdir($this->ddevProject . '/.ddev', 0777, TRUE); + file_put_contents($this->ddevProject . '/.ddev/config.yaml', "name: x\n"); + mkdir($this->bareProject, 0777, TRUE); + } + + protected function tearDown(): void { + @unlink($this->ddevProject . '/.ddev/config.yaml'); + @rmdir($this->ddevProject . '/.ddev'); + @rmdir($this->ddevProject); + @rmdir($this->bareProject); + } + + public function testHostRunInDdevProjectIsRefused(): void { + $msg = Comingling::hostInDdevProjectError($this->ddevProject, ['IS_DDEV_PROJECT' => '', 'DQ_ALLOW_HOST' => '']); + $this->assertNotNull($msg); + $this->assertStringContainsString('DDEV', $msg); + } + + public function testInsideDdevProceeds(): void { + $this->assertNull(Comingling::hostInDdevProjectError($this->ddevProject, ['IS_DDEV_PROJECT' => 'true', 'DQ_ALLOW_HOST' => ''])); + } + + public function testEscapeHatchProceeds(): void { + $this->assertNull(Comingling::hostInDdevProjectError($this->ddevProject, ['IS_DDEV_PROJECT' => '', 'DQ_ALLOW_HOST' => '1'])); + } + + public function testBareHostProjectProceeds(): void { + // No .ddev/config.yaml → not a DDEV project → nothing to guard. + $this->assertNull(Comingling::hostInDdevProjectError($this->bareProject, ['IS_DDEV_PROJECT' => '', 'DQ_ALLOW_HOST' => ''])); + } + + public function testMissingEnvKeysTreatedAsUnset(): void { + $this->assertNotNull(Comingling::hostInDdevProjectError($this->ddevProject, [])); + } + +}