irving-frias / drupal-watcher
Drupal file watcher — monitors custom modules/themes and auto-runs drush cache clears on file changes. Development tool only, not required in production.
Package info
github.com/irving-frias/drupal-watcher
Language:Go
pkg:composer/irving-frias/drupal-watcher
Requires
- php: >=8.1
This package is auto-updated.
Last update: 2026-08-08 00:44:48 UTC
README
File watcher for Drupal development. Monitors custom modules and themes and auto-runs drush cache clears whenever a file changes. Works with DDEV, Lando, and any local environment.
Requirements
- PHP 8.1+ (for Composer-based install)
- Drush installed in your Drupal project
Go is not required. The binary is auto-downloaded during
composer install.
Development tool only. Install with
--devto exclude from production deployments viacomposer install --no-dev.
Installation
composer require --dev irving-frias/drupal-watcher
vendor/bin/drupal-watcher help
On first run, the shell launcher downloads the correct binary for your OS/architecture from GitHub Releases. No compilation needed.
If
vendor/bin/drupal-watcherdoesn't exist (e.g. on Windows), use the full path:vendor/irving-frias/drupal-watcher/bin/drupal-watcher
Manual download
Download the binary for your platform from GitHub Releases, make it executable, and run it from your Drupal project root.
Quick Start
cd /path/to/drupal/project
vendor/bin/drupal-watcher start
On first run, a configs/config.yaml is auto-created with defaults. Edit it to customize routes, patterns, and cache clear commands. You can also override any config value via environment variables (e.g. DRUPAL_WATCHER_DEBOUNCE=150).
You can validate your configuration and environment with:
vendor/bin/drupal-watcher validate
This checks YAML syntax, Drupal root, watched routes, drush, PHPCS, sites, and commandsPerPattern.
The TUI opens automatically. Events appear in real-time, and you can type commands at the prompt:
● drupal-watcher PID: 12345 Uptime: 5m
Memory: 2.1 MB ▂▃▄▅▆▇█ | Changes: 14 | Clears: 3 | ⚡ x5 ▓▓▓▓░░░░ | site1: 2 site2: 1
┌──────────────────────────────────────────────────────────────┐
│ 10:00:01 ℹ Waiting for file changes... │
│ 10:02:15 ℹ Change detected: docroot/modules/custom/foo.module │
│ 10:02:16 ✔ drush cc plugin (312ms, exit 0) │
│ 10:03:22 ✖ Error in docroot/modules/custom/bad.php: │
│ PHP Parse error: syntax error, unexpected ... │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
> help │
└──────────────────────────────────────────────────────────────┘
Use --no-tui to run the classic interactive CLI instead.
Commands
| Command | Description |
|---|---|
start |
Start watching (opens TUI by default) |
validate |
Validate config, paths, drush, PHPCS |
tui |
Terminal UI (default for start) |
status |
Show running status and uptime |
list / config |
Display current configuration |
add [pattern] |
Add route and/or pattern to watch |
remove / rm |
Remove route and/or pattern |
restart |
Restart the watcher |
stop / reset |
Stop the watcher and clear PID |
version |
Show version and Go runtime |
help |
Show usage information |
Options
| Flag | Description |
|---|---|
--root <path> |
Drupal root directory (default: cwd) |
--debounce <ms> |
Debounce interval (default: 800ms) |
--no-dotfiles |
Exclude dotfiles from watching |
--no-tui |
Disable TUI, use interactive CLI mode |
--notify |
Send desktop notification on cache clear |
--log-file <path> |
Write logs to file |
--config <path> |
Custom config file path |
--commands-per-pattern <json> |
Override per-pattern commands |
--site <names> |
Only watch specified sites (comma-separated) |
--exclude-site <names> |
Watch all sites except specified |
--uri <uri> |
Single-site mode with explicit URI |
--help / -h |
Show help |
--version / -V |
Show version and Go runtime |
--notify
Sends a native OS desktop notification each time a cache clear completes:
vendor/bin/drupal-watcher start --notify
Uses the beeep Go library for cross-platform desktop notifications. No OS-specific configuration required — works on macOS (via osascript), Linux (via notify-send or D-Bus), and Windows (via Toast notifications).
--root
Point the watcher at a different Drupal root:
vendor/bin/drupal-watcher start --root /var/www/html vendor/bin/drupal-watcher status --root /var/www/html
--commands-per-pattern
Override per-pattern drush commands without editing the config file:
vendor/bin/drupal-watcher start --commands-per-pattern '{"css":"cc css-js","js":"cc css-js"}'
Accepts a JSON object mapping file extensions to drush commands.
TUI Commands
While the TUI is running, type commands at the prompt:
| Command | Description |
|---|---|
status |
Show stats, memory, and kernel watches |
stats |
Clear counts per site |
filter <site> |
Filter events by site name |
help |
Show available commands and keybinds |
star |
Open GitHub repo in browser |
dismiss |
Hide the star banner permanently |
powermode |
Toggle PowerMode visual effects |
logo |
Toggle Drupal logo side panel |
dashboard |
Toggle live dashboard panel |
stop / quit / exit |
Stop the watcher |
TUI keybinds
| Key | Description |
|---|---|
Ctrl+C / Ctrl+D |
Quit |
? |
Toggle help / Esc to close |
↑ / ↓ |
Navigate command history |
PgUp / PgDn |
Page up/down in event log |
Home |
Scroll to top |
End |
Toggle auto-scroll |
Tab |
Complete commands / site names |
Insert |
File system path scan for autocomplete |
Delete |
Cancel pending completions |
F2 |
Open interactive filter panel (extension) |
F4 |
Toggle PowerMode visual effects |
r |
Get a context-aware training suggestion |
Ctrl+X |
Disable Xdebug if detected |
The status line shows memory usage with a live sparkline, change/clear counters, and per-site clear breakdowns when multiple sites are active.
PowerMode — Overheat System 🔥
PowerMode adds energetic visual feedback when the watcher detects rapid file changes. Inspired by the VS Code Power Mode extension, it turns bursts of activity into a dynamic overheating effect — as if the watcher is working so hard it's about to catch fire.
When multiple file changes or cache clears arrive within a short window (2s), a combo counter builds up and the UI progressively overheats:
| Combo | Level | Visual Effects |
|---|---|---|
| 0-2 | Normal | No effects |
| 3-5 | Warm | Status bar border turns orange, combo counter (⚡) and energy bar appear |
| 6-10 | Hot | Border shifts to deep orange, sparks (✦ ✧ ⚡) fly upward, screen pulses, combo icon becomes 🔥 |
| 11+ | 🔥 Power | Red animated border, fire explosions (🔥 💥) burst on each level-up, embers float up, intense screen pulse, combo icon becomes 💥 |
The combo counter (⚡ x5 / 🔥 x8 / 💥 x12) and energy bar (▓▓▓▓░░░░) appear on the status line when activity ramps up. Energy decays during idle periods, cooling the system down.
Particle system
| Particle type | Behavior | Characters |
|---|---|---|
| Sparks | Fast, short-lived, fly upward at random angles | ✦ ✧ ⚡ ★ ♦ |
| Fire | Medium-lived, wobbling upward trajectory | 🔥 💥 ⚡ |
| Smoke | Slow, rising, expands horizontally, long fade | · ‧ ∘ ° ≈ |
On each level transition (Warm→Hot, Hot→Power) a radial explosion bursts particles outward in all directions — 15 sparks at Hot, 25 at Power. The energy bar pulses yellow-white while the glow effect is active.
Cooldown ❄
When events stop arriving and energy decays, the system enters cooldown mode:
- Status bar border turns blue
- Combo icon changes to ❄ (snowflake)
- Energy bar pulses with a blue draining animation
- Smoke particles rise from the bottom as the system cools — more smoke at higher levels
- Status line shows a "❄ cooling" indicator
- Cooldown tapers off gradually over ~30 ticks
- A new event immediately cancels cooldown and resumes heating
Massive batch — 💀 Skull of Death
When 50+ files change in a single batch (e.g. drush cex, git checkout, composer install), PowerMode triggers a skull event:
- Combo icon becomes 💀, status line shows "💀 MASSIVE BATCH"
- Border flashes white/red rapidly
- Energy maxes out instantly, particles explode
- Large 💀 particles float upward for several seconds
- At 100+ files: 2 skulls; at 200+ files: 3 skulls
- Skull timer lasts ~20 ticks
Toggle PowerMode on/off at any time with F4 or the powermode command.
Drupal Logo — Side Panel
An animated ASCII Drupal drop logo rotates in a side panel on the right of the events viewport. The logo cycles through6 frames at 1 frame per second (6-second full rotation).
The side panel appears when showLogo is enabled (default: true). On narrow terminals (< 60 cols), the logo auto-hides to preserve readability.
logo # toggle the side panel on/off
The preference is persisted to configs/config.yaml and survives restarts. Override with DRUPAL_WATCHER_SHOW_LOGO=false.
Interactive CLI Commands
When running with --no-tui, type commands at the prompt:
| Command | Description |
|---|---|
status |
Show stats, memory, and kernel watches |
list / config |
Show current configuration |
stats |
Show runtime statistics and memory |
add <route> |
Add a route and restart watcher |
remove <route> |
Remove a route and restart watcher |
reload |
Reload config from file |
help |
Show available commands |
stop / quit / exit |
Stop the watcher |
Configuration
Config is read from configs/config.yaml (auto-created on first run). Falls back to watcher.config.json for legacy setups.
routes: - docroot/modules/custom - docroot/themes/custom patterns: - .php; .module; .inc; .yml; .html.twig; .twig; .css; .js debounce: 800 commandsPerPattern: .html.twig: cc render .twig: cc render .theme: cc theme-registry .module: cc plugin .inc: cc plugin .php: cc plugin .yml: cc plugin .info.yml: cr .services.yml: cr .routing.yml: cr .permissions.yml: cr .links.menu.yml: cr .css: cc css-js .js: cc css-js skipLint: false lintCommands: .php: php -l .yml: yaml .yaml: yaml phpCsStandard: "" watchMode: auto pollInterval: 2000 eventBufferSize: 500
Any value can be overridden via environment variables with the DRUPAL_WATCHER_ prefix:
| Env var | Config key | Example |
|---|---|---|
DRUPAL_WATCHER_DEBOUNCE |
debounce |
DRUPAL_WATCHER_DEBOUNCE=500 |
DRUPAL_WATCHER_DRUSH_COMMAND |
drushCommand |
DRUPAL_WATCHER_DRUSH_COMMAND=cc all |
DRUPAL_WATCHER_SKIP_LINT |
skipLint |
DRUPAL_WATCHER_SKIP_LINT=true |
DRUPAL_WATCHER_WATCH_MODE |
watchMode |
DRUPAL_WATCHER_WATCH_MODE=poll |
DRUPAL_WATCHER_SHOW_LOGO |
showLogo |
DRUPAL_WATCHER_SHOW_LOGO=false |
DRUPAL_WATCHER_POLL_INTERVAL |
pollInterval |
DRUPAL_WATCHER_POLL_INTERVAL=1000 |
| Field | Description |
|---|---|
routes |
Directories to watch (relative to Drupal root) |
patterns |
File extensions to trigger cache clears on |
debounce |
Milliseconds to wait before running drush after a change |
drushCmd |
Custom path to the drush binary (auto-detected if omitted) |
drushCommand |
Default drush command (default: cr) |
drushArgs |
Extra arguments to pass to drush |
commandsPerPattern |
Maps file extensions to specific drush commands |
excludePatterns |
Path substrings to exclude from watching |
Sites |
Site names to watch in multi-site setups (resolved via drush/sites.yml) |
skipLint |
Disable lint checking before cache clear |
lintCommands |
Per-extension lint commands (default: php -l for PHP, Go yaml parser for YAML). Only checks files inside routes. |
phpCsStandard |
PHPCS standard for PHP linting, e.g. "auto", "Drupal", "DrupalStrict". When set, replaces php -l with phpcs using Drupal coding standards. "auto" detects Drupal 11 → DrupalStrict, else Drupal. Empty string (default) keeps php -l. |
watchMode |
File watching mode: auto, fsnotify, poll, hybrid |
pollInterval |
Polling interval in ms (default 2000, only used in poll/hybrid modes) |
showLogo |
Show animated Drupal logo in side panel (default: true). Toggle with logo command. |
commandsPerPattern maps file extensions to drush commands. The most specific match wins (e.g., .info.yml matches before .yml). Falls back to cr if no pattern matches.
Watch modes
| Mode | Description |
|---|---|
auto (default) |
Tries fsnotify first; falls back to polling if OS limits are hit |
fsnotify |
Native file system events only (lowest latency) |
poll |
Periodic file tree scan at pollInterval (works around OS limits) |
hybrid |
Runs both fsnotify and polling simultaneously; events are deduplicated within a 1s window. Provides the reliability of polling with the low latency of fsnotify. |
Config via watchMode in configs/config.yaml (or legacy watcher.config.json) or override per session with DRUPAL_WATCHER_WATCH_MODE=poll. Polling mode is useful in large projects that exceed fs.inotify.max_user_watches on Linux, or when running in shared filesystems like NFS.
How it works
-
drupal-watcher startloads config, detects the Drupal docroot, and writes a PID file -
Uses
fsnotifyto watch all subdirectories under the configured routes (falls back to polling if fsnotify fails, or use hybrid mode for both) -
When files change, debounces (default 800ms) collecting all changes into a batch. File changes appear instantly in the TUI event log — only the cache clear waits for the debounce window.
-
PHP and YAML files are linted before running drush (
php -lorphpcsfor PHP, Go yaml parser for YAML). Lint results are cached with a SHA-1 content hash and 5-minute TTL — unchanged files are not re-checked. Only files inside watchedroutesare checked. If linting fails, the cache clear is skipped and the error (with file path) is displayed in the TUI.When
phpCsStandardis set in the config, PHP files are checked withphpcsusing Drupal coding standards (auto-detectsDrupalStrictfor Drupal 11,Drupalfor Drupal 10). Requiresdrupal/coderandsquizlabs/php_codesnifferinstalled via Composer. -
Compatible cache clear commands are merged into a single
drushcall (e.g.drush cc render,plugin,css-js) -
If any change requires a full rebuild (
cr), it applies a lazy rebuild — a separate 2-second debounce timer accumulates all changes and executes a singledrush crat the end of the burst, avoiding redundant full rebuilds -
Drush output is displayed in the TUI or printed to the terminal
-
A health file is written to
~/.cache/drupal-watcher/healthevery 30s (cleaned up on shutdown) -
Metrics (changes, clears, errors per minute) are tracked in-memory for the training mode and
statscommand -
Ctrl+C(orSIGTERM) cancels the context, stops all modules with a 10s timeout, removes PID and health files
Drush optimizations
The watcher applies several optimizations to minimize overhead:
| Optimization | Description |
|---|---|
| Binary caching | Resolved drush path cached after first lookup, avoids repeated $PATH scans |
| Batch cache clears | Multiple cc <type> commands merged into a single drush cc type1,type2,... call |
cr overrides |
If any change requires drush cr, a 2-second lazy rebuild timer accumulates all changes first |
| Lint cache | SHA-1 content hash avoids re-linting unchanged files (5-minute TTL, max 1000 entries) |
| Parallel multi-site | Per-site drush runs in a worker pool (up to 3 concurrent) for multi-site projects |
| Quiet mode | Drush runs with --quiet --no-ansi by default for minimal output overhead |
On large debounce windows, rapid changes to different file types share a single PHP bootstrap instead of spawning separate processes.
Drupal root detection
The watcher scans for docroot/, web/, public/, or html/ directories containing core/, modules/, themes/, or index.php. The detected root is stored in the config file.
Cache clear per pattern
| Extension | Drush command |
|---|---|
.html.twig |
cc render |
.twig |
cc render |
.theme |
cc theme-registry |
.module |
cc plugin |
.inc |
cc plugin |
.php |
cc plugin |
.yml |
cc plugin |
.info.yml |
cr |
.services.yml |
cr |
.routing.yml |
cr |
.permissions.yml |
cr |
.links.menu.yml |
cr |
.css |
cc css-js |
.js |
cc css-js |
Twig debug mode (development only)
Enable Twig development mode without touching settings.php:
drush twig:debug on # enables Twig debug + auto-disable cache drush twig:debug off # restores production settings
Available since Drush 12.1+. Handles twig.config settings automatically — no manual cache clears needed.
Multi-site
Drupal Watcher supports multi-site setups with a single watcher process. When multiple sites are detected under sites/, drush runs in parallel goroutines for each site on every file change.
Auto-detection
The watcher checks for directories under sites/ beyond default/ that contain settings.php. If only sites/default/ exists, single-site mode is used (no changes to existing workflows).
Site aliases (required for multi-site)
When multiple sites are detected, you must configure site aliases so the watcher knows each site's URI. The watcher supports both formats Drush accepts:
Combined file: drush/sites.yml
# docroot/drush/sites.yml site1: uri: 'https://site1.local' site2: uri: 'https://site2.local'
Per-site files: drush/sites/{name}.site.yml
drush/
├── drush.yml
└── sites
├── site1.site.yml
├── site2.site.yml
└── site3.site.yml
Each {name}.site.yml file defines one site alias. For example:
# drush/sites/site1.site.yml site1: uri: 'https://site1.local'
# drush/sites/site2.site.yml site2: uri: 'https://site2.local'
The per-site directory format is discovered automatically by scanning drush/sites/*.site.yml. The two formats are mutually exclusive — if drush/sites.yml exists, the directory is ignored.
The watcher does not guess URIs from directory names — a site alias can differ from its directory name. See Drush site aliases docs for details.
If multi-site is detected and neither drush/sites.yml nor drush/sites/*.site.yml exists, the watcher exits with an error and instructions.
Filtering sites
| Flag | Example | Description |
|---|---|---|
--site |
--site=site1,site2 |
Whitelist — only watch these sites |
--exclude-site |
--exclude-site=site3 |
Blacklist — watch all except these |
--uri |
--uri=https://site1.local |
Override single-site mode with a specific URI (skips detection) |
Config persistence
The Sites field in watcher.config.json persists the site list from a previous run:
{
"routes": ["docroot/modules/custom"],
"Sites": ["site1", "site2"]
}
When Sites is present in the config file, it's auto-resolved against drush/sites.yml on startup. This is useful when you always work with the same subset of sites.
TUI display
Events in the TUI are tagged with the site name:
10:00:01 ✔ drush cc plugin [site1] (312ms, exit 0)
10:00:01 ✔ drush cc plugin [site2] (289ms, exit 0)
PID management
The watcher writes a PID file to ~/.cache/drupal-watcher/.drupal-watcher-<project-hash>.pid (0600 permissions) to prevent multiple instances. The filename includes a hash of the project root, so you can run the watcher in multiple projects without conflicts. If the process crashes, stale PID files are cleaned up automatically on the next start.
Architecture
The codebase uses a hexagonal (ports & adapters) architecture:
cmd/
drupal-watcher/ → Binary (modular entry point with DI container + EventBus)
internal/
app/
app.go → DI setup (Setup/Shutdown via samber/do/v2)
common/types.go → Typed string wrappers (WorkDir, DrupalRoot)
eventbus/
bus.go → Pub/sub event bus (async, topic-based)
modules/
config/ → Config module (loads configs/config.yaml, stores in container)
watcher/ → Watcher module (creates FSNotifyWatcher from config)
executor/ → Executor module (creates DrushExecutor from config)
orchestrator/ → Orchestrator module (engine with EventBus, starts in goroutine)
ui/ → UI module (runs Bubble Tea TUI, blocks until quit)
providers/tui/ → TUI bridge (EventBus → EngineEvent channel)
config/ → Config management (YAML + env vars), Drupal root detection, PID files
health/ → Liveness check (timestamp file every 30s)
drush/ → Drush resolution, execution, health checks
metrics/ → Runtime statistics (changes, clears, errors per minute)
training/ → Context-aware training suggestions (training.json)
validate/ → Config and environment validation (`validate` command)
xdebug/ → Xdebug detection and disable (Ctrl+X)
utils/ → Color helpers, format utilities
pkg/
core/
interfaces.go → Watcher, CommandExecutor, EventFilter, LintChecker
models.go → FileEvent, ExecutionResult, EngineEvent, SiteInfo
adapters/
fsnotify_watcher.go → core.Watcher via fsnotify
polling_watcher.go → core.Watcher via periodic file tree scan
hybrid_watcher.go → core.Watcher (fsnotify + polling, deduped)
drush_executor.go → core.CommandExecutor via drush
regex_filter.go → Pattern/Exclude/Dotfile filters
php_lint.go → core.LintChecker via php -l
yaml_lint.go → core.LintChecker via Go yaml parser
lint_cache.go → Caching wrapper for LintChecker (SHA-1, 5min TTL)
phpcs_lint.go → core.LintChecker via phpcs (Drupal standards)
slog_logger.go → Structured logger factory
Key domain interfaces
// pkg/core/interfaces.go type Watcher interface { Start(ctx context.Context) (<-chan FileEvent, <-chan error) Add(path string) error Remove(path string) error Close() error } type CommandExecutor interface { Execute(ctx context.Context, commands []string, dir string) ExecutionResult } type EventFilter interface { ShouldProcess(event FileEvent) bool } type LintChecker interface { Lint(filePath string) *LintResult } type LintResult struct { File string Error string }
Engine event loop
The orchestrator (internal/app/modules/orchestrator/engine.go) runs the central pipeline:
- Watcher emits raw
FileEventon a channel (via fsnotify, polling, or hybrid) - All
EventFilterimplementations decide if the event should be processed - Debounce timer groups rapid changes into a single batch
- Lint check: each changed file is checked by its
LintChecker(.php→php -l,.yml→ Go yaml parser). If any file fails, the batch is skipped and anerrorEventBus event is published. - Matching file extensions are resolved to drush commands via
CommandsPerPattern CommandExecutorruns the resolved commandsEngineEventis published to the EventBus onfile.changeandcache.cleartopics
Development (requires Go 1.25+)
go test -count=1 ./...
go vet ./...
go build -o drupal-watcher ./cmd/drupal-watcher
Cross-compilation
GOOS=linux GOARCH=amd64 go build -o drupal-watcher-linux-amd64 ./cmd/drupal-watcher GOOS=linux GOARCH=arm64 go build -o drupal-watcher-linux-arm64 ./cmd/drupal-watcher GOOS=darwin GOARCH=amd64 go build -o drupal-watcher-darwin-amd64 ./cmd/drupal-watcher GOOS=darwin GOARCH=arm64 go build -o drupal-watcher-darwin-arm64 ./cmd/drupal-watcher GOOS=windows GOARCH=amd64 go build -o drupal-watcher-windows-amd64.exe ./cmd/drupal-watcher
CI builds all platforms automatically on push to main and publishes releases.
License
MIT