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