1#!/usr/bin/env php 2<?php 3 4use dokuwiki\Extension\CLIPlugin; 5use dokuwiki\Extension\PluginController; 6use dokuwiki\plugin\dev\LangProcessor; 7use dokuwiki\plugin\dev\Skeletor; 8use dokuwiki\plugin\dev\SVGIcon; 9use splitbrain\phpcli\Exception as CliException; 10use splitbrain\phpcli\Options; 11 12/** 13 * @license GPL2 14 * @author Andreas Gohr <andi@splitbrain.org> 15 */ 16class cli_plugin_dev extends CLIPlugin 17{ 18 /** 19 * Register options and arguments on the given $options object 20 * 21 * @param Options $options 22 * @return void 23 */ 24 protected function setup(Options $options) 25 { 26 $options->useCompactHelp(); 27 $options->setHelp( 28 "CLI to help with DokuWiki plugin and template development.\n\n" . 29 "Run this script from within the extension's directory." 30 ); 31 32 $options->registerCommand('init', 'Initialize a new plugin or template in the current directory.'); 33 $options->registerCommand('addTest', 'Add the testing framework files and a test. (_test/)'); 34 $options->registerArgument( 35 'test', 36 'Optional name of the new test. Defaults to the general test.', 37 false, 38 'addTest' 39 ); 40 $options->registerCommand('addConf', 'Add the configuration files. (conf/)'); 41 $options->registerCommand('addLang', 'Add the language files. (lang/)'); 42 $options->registerCommand('addAgents', 'Add an initial AGENTS.md file for guiding LLM coding agents'); 43 $options->registerOption( 44 'claude', 45 'Symlink the AGENTS.md to CLAUDE.md for use with claude code', 46 'c', 47 false, 48 'addAgents' 49 ); 50 51 $types = PluginController::PLUGIN_TYPES; 52 array_walk( 53 $types, 54 function (&$item) { 55 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 56 } 57 ); 58 59 $options->registerCommand('addComponent', 'Add a new plugin component.'); 60 $options->registerArgument( 61 'type', 62 'Type of the component. Needs to be one of ' . implode(', ', $types), 63 true, 64 'addComponent' 65 ); 66 $options->registerArgument( 67 'name', 68 'Optional name of the component. Defaults to a base component.', 69 false, 70 'addComponent' 71 ); 72 73 $options->registerCommand('deletedFiles', 'Create the list of deleted files based on the git history.'); 74 $options->registerCommand('rmObsolete', 'Delete obsolete files.'); 75 76 $prefixes = array_keys(SVGIcon::SOURCES); 77 array_walk( 78 $prefixes, 79 function (&$item) { 80 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 81 } 82 ); 83 84 $options->registerCommand('downloadSvg', 'Download an SVG file from a known icon repository.'); 85 $options->registerArgument( 86 'prefix:name', 87 'Colon-prefixed name of the icon. Available prefixes: ' . implode(', ', $prefixes), 88 true, 89 'downloadSvg' 90 ); 91 $options->registerArgument( 92 'output', 93 'File to save, defaults to <name>.svg in current dir', 94 false, 95 'downloadSvg' 96 ); 97 $options->registerOption( 98 'keep-ns', 99 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 100 'k', 101 false, 102 'downloadSvg' 103 ); 104 105 $options->registerCommand('cleanSvg', 'Clean a existing SVG files to reduce their file size.'); 106 $options->registerArgument('files...', 'The files to clean (will be overwritten)', true, 'cleanSvg'); 107 $options->registerOption( 108 'keep-ns', 109 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 110 'k', 111 false, 112 'cleanSvg' 113 ); 114 115 $options->registerCommand( 116 'cleanLang', 117 'Clean language files from unused language strings. Detecting which strings are truly in use may ' . 118 'not always correctly work. Use with caution.' 119 ); 120 121 $options->registerCommand( 122 'test', 123 'Run the unit tests for this extension. (calls phpunit using the proper config and group)' 124 ); 125 $options->registerOption( 126 'filter', 127 'Filter tests to run by a given string. (passed to phpunit)', 128 null, 129 true, 130 'test' 131 ); 132 $options->registerArgument('files...', 'The test files to run. Defaults to all.', false, 'test'); 133 134 $options->registerCommand('check', 'Check for code style violations.'); 135 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'check'); 136 137 $options->registerCommand('fix', 'Fix code style violations and refactor outdated code.'); 138 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'fix'); 139 } 140 141 /** @inheritDoc */ 142 protected function main(Options $options) 143 { 144 $args = $options->getArgs(); 145 146 switch ($options->getCmd()) { 147 case 'init': 148 return $this->cmdInit(); 149 case 'addTest': 150 $test = array_shift($args); 151 return $this->cmdAddTest($test); 152 case 'addConf': 153 return $this->cmdAddConf(); 154 case 'addLang': 155 return $this->cmdAddLang(); 156 case 'addAgents': 157 $claude = $options->getOpt('claude'); 158 return $this->cmdAddAgents($claude); 159 case 'addComponent': 160 $type = array_shift($args); 161 $component = array_shift($args); 162 return $this->cmdAddComponent($type, $component); 163 case 'deletedFiles': 164 return $this->cmdDeletedFiles(); 165 case 'rmObsolete': 166 return $this->cmdRmObsolete(); 167 case 'downloadSvg': 168 $ident = array_shift($args); 169 $save = array_shift($args); 170 $keep = $options->getOpt('keep-ns'); 171 return $this->cmdDownloadSVG($ident, $save, $keep); 172 case 'cleanSvg': 173 $keep = $options->getOpt('keep-ns'); 174 return $this->cmdCleanSVG($args, $keep); 175 case 'cleanLang': 176 return $this->cmdCleanLang(); 177 case 'test': 178 $filter = $options->getOpt('filter'); 179 return $this->cmdTest($filter, $args); 180 case 'check': 181 return $this->cmdCheck($args); 182 case 'fix': 183 return $this->cmdFix(); 184 default: 185 $this->error('Unknown command'); 186 echo $options->help(); 187 return 0; 188 } 189 } 190 191 /** 192 * Get the extension name from the current working directory 193 * 194 * @throws CliException if something's wrong 195 * @param string $dir 196 * @return string[] name, type 197 */ 198 protected function getTypedNameFromDir($dir) 199 { 200 $pdir = fullpath(DOKU_PLUGIN); 201 $tdir = fullpath(tpl_incdir() . '../'); 202 203 if (str_starts_with($dir, $pdir)) { 204 $ldir = substr($dir, strlen($pdir)); 205 $type = 'plugin'; 206 } elseif (str_starts_with($dir, $tdir)) { 207 $ldir = substr($dir, strlen($tdir)); 208 $type = 'template'; 209 } else { 210 throw new CliException('Current directory needs to be in plugin or template directory'); 211 } 212 213 $ldir = trim($ldir, '/'); 214 215 if (str_contains($ldir, '/')) { 216 throw new CliException('Current directory has to be main extension directory'); 217 } 218 219 return [$ldir, $type]; 220 } 221 222 /** 223 * Interactively ask for a value from the user 224 * 225 * @param string $prompt 226 * @param bool $cache cache given value for next time? 227 * @return string 228 */ 229 protected function readLine($prompt, $cache = false) 230 { 231 $value = ''; 232 $default = ''; 233 $cachename = getCacheName($prompt, '.readline'); 234 if ($cache && file_exists($cachename)) { 235 $default = file_get_contents($cachename); 236 } 237 238 while ($value === '') { 239 echo $prompt; 240 if ($default) echo ' [' . $default . ']'; 241 echo ': '; 242 243 $fh = fopen('php://stdin', 'r'); 244 $value = trim(fgets($fh)); 245 fclose($fh); 246 247 if ($value === '') $value = $default; 248 } 249 250 if ($cache) { 251 file_put_contents($cachename, $value); 252 } 253 254 return $value; 255 } 256 257 /** 258 * Create the given files with their given content 259 * 260 * Ignores all files that already exist 261 * 262 * @param array $files A File array as created by Skeletor::getFiles() 263 */ 264 protected function createFiles($files) 265 { 266 foreach ($files as $path => $content) { 267 if (file_exists($path)) { 268 $this->error($path . ' already exists'); 269 continue; 270 } 271 272 io_makeFileDir($path); 273 file_put_contents($path, $content); 274 $this->success($path . ' created'); 275 } 276 } 277 278 /** 279 * Delete the given file if it exists 280 * 281 * @param string $file 282 */ 283 protected function deleteFile($file) 284 { 285 if (!file_exists($file)) return; 286 if (@unlink($file)) { 287 $this->success('Delete ' . $file); 288 } 289 } 290 291 /** 292 * Run git with the given arguments and return the output 293 * 294 * @throws CliException when the command can't be run 295 * @param string ...$args 296 * @return string[] 297 */ 298 protected function git(...$args) 299 { 300 $args = array_map(escapeshellarg(...), $args); 301 $cmd = 'git ' . implode(' ', $args); 302 $output = []; 303 $result = 0; 304 305 $this->info($cmd); 306 $last = exec($cmd, $output, $result); 307 if ($last === false || $result !== 0) { 308 throw new CliException('Running git failed'); 309 } 310 311 return $output; 312 } 313 314 // region Commands 315 316 /** 317 * Intialize the current directory as a plugin or template 318 * 319 * @return int 320 */ 321 protected function cmdInit() 322 { 323 $dir = fullpath(getcwd()); 324 if ((new FilesystemIterator($dir))->valid()) { 325 // existing directory, initialize from info file 326 $skeletor = Skeletor::fromDir($dir); 327 } else { 328 // new directory, ask for info 329 [$base, $type] = $this->getTypedNameFromDir($dir); 330 $user = $this->readLine('Your Name', true); 331 $mail = $this->readLine('Your E-Mail', true); 332 $desc = $this->readLine('Short description'); 333 $skeletor = new Skeletor($type, $base, $desc, $user, $mail); 334 } 335 $skeletor->addBasics(); 336 $this->createFiles($skeletor->getFiles()); 337 338 if (!is_dir("$dir/.git")) { 339 try { 340 $this->git('init'); 341 } catch (CliException $e) { 342 $this->error($e->getMessage()); 343 } 344 } 345 346 return 0; 347 } 348 349 /** 350 * Add test framework 351 * 352 * @param string $test Name of the Test to add 353 * @return int 354 */ 355 protected function cmdAddTest($test = '') 356 { 357 $skeletor = Skeletor::fromDir(getcwd()); 358 $skeletor->addTest($test); 359 $this->createFiles($skeletor->getFiles()); 360 return 0; 361 } 362 363 /** 364 * Add configuration 365 * 366 * @return int 367 */ 368 protected function cmdAddConf() 369 { 370 $skeletor = Skeletor::fromDir(getcwd()); 371 $skeletor->addConf(is_dir('lang')); 372 $this->createFiles($skeletor->getFiles()); 373 return 0; 374 } 375 376 /** 377 * Add language 378 * 379 * @return int 380 */ 381 protected function cmdAddLang() 382 { 383 $skeletor = Skeletor::fromDir(getcwd()); 384 $skeletor->addLang(is_dir('conf')); 385 $this->createFiles($skeletor->getFiles()); 386 return 0; 387 } 388 389 /** 390 * Add AGENTS.md 391 * 392 * @return int 393 */ 394 protected function cmdAddAgents($claude) 395 { 396 $skeletor = Skeletor::fromDir(getcwd()); 397 $skeletor->addAgents(); 398 $this->createFiles($skeletor->getFiles()); 399 if ($claude && !file_exists('CLAUDE.md')) { 400 if (symlink('AGENTS.md', 'CLAUDE.md')) { 401 $this->success('Created symlink CLAUDE.md -> AGENTS.md'); 402 } 403 } 404 return 0; 405 } 406 407 /** 408 * Add another component to the plugin 409 * 410 * @param string $type 411 * @param string $component 412 */ 413 protected function cmdAddComponent($type, $component = '') 414 { 415 $skeletor = Skeletor::fromDir(getcwd()); 416 $skeletor->addComponent($type, $component); 417 $this->createFiles($skeletor->getFiles()); 418 return 0; 419 } 420 421 /** 422 * Generate a list of deleted files from git 423 * 424 * @link https://stackoverflow.com/a/6018049/172068 425 */ 426 protected function cmdDeletedFiles() 427 { 428 if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git'); 429 430 $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D'); 431 $output = array_map(trim(...), $output); 432 $output = array_filter($output); 433 $output = array_unique($output); 434 $output = array_filter($output, fn($item) => !file_exists($item)); 435 sort($output); 436 437 if ($output === []) { 438 $this->info('No deleted files found'); 439 return 0; 440 } 441 442 $content = "# This is a list of files that were present in previous releases\n" . 443 "# but were removed later. They should not exist in your installation.\n" . 444 implode("\n", $output) . "\n"; 445 446 file_put_contents('deleted.files', $content); 447 $this->success('written deleted.files'); 448 return 0; 449 } 450 451 /** 452 * Remove files that shouldn't be here anymore 453 */ 454 protected function cmdRmObsolete() 455 { 456 $this->deleteFile('_test/general.test.php'); 457 $this->deleteFile('.travis.yml'); 458 $this->deleteFile('.github/workflows/phpTestLinux.yml'); 459 460 return 0; 461 } 462 463 /** 464 * Download a remote icon 465 * 466 * @param string $ident 467 * @param string $save 468 * @param bool $keep 469 * @return int 470 * @throws Exception 471 */ 472 protected function cmdDownloadSVG($ident, $save = '', $keep = false) 473 { 474 $svg = new SVGIcon($this); 475 $svg->keepNamespace($keep); 476 return (int)$svg->downloadRemoteIcon($ident, $save); 477 } 478 479 /** 480 * @param string[] $files 481 * @param bool $keep 482 * @return int 483 * @throws Exception 484 */ 485 protected function cmdCleanSVG($files, $keep = false) 486 { 487 $svg = new SVGIcon($this); 488 $svg->keepNamespace($keep); 489 490 $ok = true; 491 foreach ($files as $file) { 492 $ok = $ok && $svg->cleanSVGFile($file); 493 } 494 return (int)$ok; 495 } 496 497 /** 498 * @return int 499 */ 500 protected function cmdCleanLang() 501 { 502 $lp = new LangProcessor($this); 503 504 $files = glob('./lang/*/lang.php'); 505 foreach ($files as $file) { 506 $lp->processLangFile($file); 507 } 508 509 $files = glob('./lang/*/settings.php'); 510 foreach ($files as $file) { 511 $lp->processSettingsFile($file); 512 } 513 514 return 0; 515 } 516 517 /** 518 * Run the unit tests for this extension 519 * 520 * @param string $filter Optional filter string for phpunit 521 * @param string[] $args Additional arguments to pass to phpunit (files) 522 * @return int 523 */ 524 protected function cmdTest($filter = '', $args = []) 525 { 526 $dir = fullpath(getcwd()); 527 [$base, $type] = $this->getTypedNameFromDir($dir); 528 529 if ($this->colors->isEnabled()) { 530 $colors = 'always'; 531 } else { 532 $colors = 'never'; 533 } 534 535 $bin = fullpath(__DIR__ . '/../../../_test/vendor/bin/phpunit'); 536 ; 537 if (!file_exists($bin)) { 538 $this->error('Testing framework not found. Please run "composer install" in the _test/ directory first.'); 539 return 1; 540 } 541 542 $runArgs = [ 543 $bin, 544 '--verbose', 545 "--colors=$colors", 546 '--configuration', fullpath(__DIR__ . '/../../../_test/phpunit.xml'), 547 '--group', $type . '_' . $base, 548 ]; 549 if ($filter) { 550 $runArgs[] = '--filter'; 551 $runArgs[] = $filter; 552 } 553 554 $runArgs = array_merge($runArgs, $args); 555 $cmd = implode(' ', array_map(escapeshellarg(...), $runArgs)); 556 $this->info("Running $cmd"); 557 558 $result = 0; 559 passthru($cmd, $result); 560 return $result; 561 } 562 563 /** 564 * @return int 565 */ 566 protected function cmdCheck($files = []) 567 { 568 $dir = fullpath(getcwd()); 569 570 $args = [ 571 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcs'), 572 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 573 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 574 '--', 575 ]; 576 577 if ($files) { 578 $args = array_merge($args, $files); 579 } else { 580 $args[] = fullpath($dir); 581 } 582 583 $cmd = implode(' ', array_map(escapeshellarg(...), $args)); 584 $this->info("Running $cmd"); 585 586 $result = 0; 587 passthru($cmd, $result); 588 return $result; 589 } 590 591 /** 592 * @return int 593 */ 594 protected function cmdFix($files = []) 595 { 596 $dir = fullpath(getcwd()); 597 598 // first run rector to refactor outdated code 599 $args = [ 600 fullpath(__DIR__ . '/../../../_test/vendor/bin/rector'), 601 ($this->colors->isEnabled()) ? '--ansi' : '--no-ansi', 602 '--config=' . fullpath(__DIR__ . '/../../../_test/rector.php'), 603 '--no-diffs', 604 'process', 605 ]; 606 607 if ($files) { 608 $args = array_merge($args, $files); 609 } else { 610 $args[] = fullpath($dir); 611 } 612 613 $cmd = implode(' ', array_map(escapeshellarg(...), $args)); 614 $this->info("Running $cmd"); 615 616 $result = 0; 617 passthru($cmd, $result); 618 if ($result !== 0) return $result; 619 620 // now run phpcbf to clean up code style 621 $args = [ 622 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcbf'), 623 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 624 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 625 '--', 626 ]; 627 628 if ($files) { 629 $args = array_merge($args, $files); 630 } else { 631 $args[] = fullpath($dir); 632 } 633 634 $cmd = implode(' ', array_map(escapeshellarg(...), $args)); 635 $this->info("Running $cmd"); 636 637 $result = 0; 638 passthru($cmd, $result); 639 return $result; 640 } 641 642 //endregion 643} 644