xref: /plugin/dev/cli.php (revision c5c85a97d56f32f460f1b227342beeed18e7ef18)
1#!/usr/bin/env php
2<?php
3
4use dokuwiki\Extension\CLIPlugin;
5use dokuwiki\Extension\PluginController;
6use splitbrain\phpcli\Exception as CliException;
7use splitbrain\phpcli\Options;
8
9/**
10 * @license GPL2
11 * @author  Andreas Gohr <andi@splitbrain.org>
12 */
13class cli_plugin_dev extends CLIPlugin
14{
15
16    /**
17     * Register options and arguments on the given $options object
18     *
19     * @param Options $options
20     * @return void
21     */
22    protected function setup(Options $options)
23    {
24        $options->setHelp(
25            "CLI to help with plugin and template development\n\n" .
26            "Run this script from within the extension's directory."
27        );
28
29        $options->registerCommand('init', 'initialize a new plugin or template');
30        $options->registerCommand('addTest', 'add the testing framework files and optionall a new test file');
31        $options->registerArgument('test', 'The name of the new test, leave out for general test.', false, 'addTest');
32        $options->registerCommand('addConf', 'add the configuration files');
33        $options->registerCommand('addLang', 'add the language files');
34
35        $options->registerCommand('addComponent', 'add a component');
36        $options->registerArgument('type', 'The type of the component. Needs to be one of ' .
37            join(', ', PluginController::PLUGIN_TYPES),
38            true, 'addComponent'
39        );
40        $options->registerArgument('name', 'Optional name of the component', false, 'addComponent');
41
42        $options->registerCommand('deletedFiles', 'create the list of deleted files base on the git history');
43        $options->registerCommand('rmObsolete', 'delete obsolete files');
44    }
45
46    /** @inheritDoc */
47    protected function main(Options $options)
48    {
49        switch ($options->getCmd()) {
50            case 'init':
51                return $this->cmdInit();
52            case 'addTest':
53                $args = $options->getArgs();
54                $test = array_shift($args);
55                return $this->cmdAddTest($test);
56            case 'addConf':
57                return $this->cmdAddConf();
58            case 'addLang':
59                return $this->cmdAddLang();
60            case 'addComponent':
61                $args = $options->getArgs();
62                $type = array_shift($args);
63                $component = array_shift($args);
64                return $this->cmdAddComponent($type, $component);
65            case 'deletedFiles':
66                return $this->cmdDeletedFiles();
67            case 'rmObsolete':
68                return $this->rmObsolete();
69            default:
70                echo $options->help();
71                return 0;
72        }
73    }
74
75    /**
76     * Get the extension name from the current working directory
77     *
78     * @throws CliException if something's wrong
79     * @param string $dir
80     * @return string[] name, type
81     */
82    protected function getTypedNameFromDir($dir)
83    {
84        $pdir = fullpath(DOKU_PLUGIN);
85        $tdir = fullpath(tpl_incdir() . '../');
86
87        if (strpos($dir, $pdir) === 0) {
88            $ldir = substr($dir, strlen($pdir));
89            $type = 'plugin';
90        } elseif (strpos($dir, $tdir) === 0) {
91            $ldir = substr($dir, strlen($tdir));
92            $type = 'template';
93        } else {
94            throw new CliException('Current directory needs to be in plugin or template directory');
95        }
96
97        $ldir = trim($ldir, '/');
98
99        if (strpos($ldir, '/') !== false) {
100            throw new CliException('Current directory has to be main extension directory');
101        }
102
103        return [$ldir, $type];
104    }
105
106    /**
107     * Interactively ask for a value from the user
108     *
109     * @param string $prompt
110     * @param bool $cache cache given value for next time?
111     * @return string
112     */
113    protected function readLine($prompt, $cache = false)
114    {
115        $value = '';
116        $default = '';
117        $cachename = getCacheName($prompt, '.readline');
118        if ($cache && file_exists($cachename)) {
119            $default = file_get_contents($cachename);
120        }
121
122        while ($value === '') {
123            echo $prompt;
124            if ($default) echo ' [' . $default . ']';
125            echo ': ';
126
127            $fh = fopen('php://stdin', 'r');
128            $value = trim(fgets($fh));
129            fclose($fh);
130
131            if ($value === '') $value = $default;
132        }
133
134        if ($cache) {
135            file_put_contents($cachename, $value);
136        }
137
138        return $value;
139    }
140
141    /**
142     * Download a skeleton file and do the replacements
143     *
144     * @param string $skel Skeleton relative to the skel dir in the repo
145     * @param string $target Target file relative to the main directory
146     * @param array $replacements
147     */
148    protected function loadSkeleton($skel, $target, $replacements)
149    {
150        if (file_exists($target)) {
151            $this->error($target . ' already exists');
152            return;
153        }
154
155        $base = 'https://raw.githubusercontent.com/dokufreaks/dokuwiki-plugin-wizard/master/skel/';
156        $http = new \dokuwiki\HTTP\DokuHTTPClient();
157        $content = $http->get($base . $skel);
158
159        $content = str_replace(
160            array_keys($replacements),
161            array_values($replacements),
162            $content
163        );
164
165        io_makeFileDir($target);
166        file_put_contents($target, $content);
167        $this->success('Added ' . $target);
168    }
169
170    /**
171     * Prepare the string replacements
172     *
173     * @param array $replacements override defaults
174     * @return array
175     */
176    protected function prepareReplacements($replacements = [])
177    {
178        // defaults
179        $data = [
180            '@@AUTHOR_NAME@@' => '',
181            '@@AUTHOR_MAIL@@' => '',
182            '@@PLUGIN_NAME@@' => '',
183            '@@PLUGIN_DESC@@' => '',
184            '@@PLUGIN_URL@@' => '',
185            '@@PLUGIN_TYPE@@' => '',
186            '@@INSTALL_DIR@@' => 'plugins',
187            '@@DATE@@' => date('Y-m-d'),
188        ];
189
190        // load from existing plugin.info
191        $dir = fullpath(getcwd());
192        [$name, $type] = $this->getTypedNameFromDir($dir);
193        if (file_exists("$type.info.txt")) {
194            $info = confToHash("$type.info.txt");
195            $data['@@AUTHOR_NAME@@'] = $info['author'];
196            $data['@@AUTHOR_MAIL@@'] = $info['email'];
197            $data['@@PLUGIN_DESC@@'] = $info['desc'];
198            $data['@@PLUGIN_URL@@'] = $info['url'];
199        }
200        $data['@@PLUGIN_NAME@@'] = $name;
201        $data['@@PLUGIN_TYPE@@'] = $type;
202
203        if ($type == 'template') {
204            $data['@@INSTALL_DIR@@'] = 'tpl';
205        }
206
207        // merge given overrides
208        $data = array_merge($data, $replacements);
209
210        // set inherited defaults
211        if (empty($data['@@PLUGIN_URL@@'])) {
212            $data['@@PLUGIN_URL@@'] =
213                'https://www.dokuwiki.org/' .
214                $data['@@PLUGIN_TYPE@@'] . ':' .
215                $data['@@PLUGIN_NAME@@'];
216        }
217
218        return $data;
219    }
220
221    /**
222     * Replacements needed for action components.
223     *
224     * Not cool but that' what we need currently
225     *
226     * @return string[]
227     */
228    protected function actionReplacements()
229    {
230        $fn = 'handleEventName';
231        $register = '        $controller->register_hook(\'EVENT_NAME\', \'AFTER|BEFORE\', $this, \'' . $fn . '\');';
232        $handler = '    public function ' . $fn . '(Doku_Event $event, $param)' . "\n"
233            . "    {\n"
234            . "    }\n";
235
236        return [
237            '@@REGISTER@@' => $register . "\n   ",
238            '@@HANDLERS@@' => $handler,
239        ];
240    }
241
242    /**
243     * Delete the given file if it exists
244     *
245     * @param string $file
246     */
247    protected function deleteFile($file)
248    {
249        if (!file_exists($file)) return;
250        if (@unlink($file)) {
251            $this->success('Delete ' . $file);
252        }
253    }
254
255    /**
256     * Run git with the given arguments and return the output
257     *
258     * @throws CliException when the command can't be run
259     * @param string ...$args
260     * @return string[]
261     */
262    protected function git(...$args)
263    {
264        $args = array_map('escapeshellarg', $args);
265        $cmd = 'git ' . join(' ', $args);
266        $output = [];
267        $result = 0;
268
269        $this->info($cmd);
270        $last = exec($cmd, $output, $result);
271        if ($last === false || $result !== 0) {
272            throw new CliException('Running git failed');
273        }
274
275        return $output;
276    }
277
278    // region Commands
279
280    /**
281     * Intialize the current directory as a plugin or template
282     *
283     * @return int
284     */
285    protected function cmdInit()
286    {
287        $dir = fullpath(getcwd());
288        if ((new FilesystemIterator($dir))->valid()) {
289            throw new CliException('Current directory needs to be empty');
290        }
291
292        [$name, $type] = $this->getTypedNameFromDir($dir);
293        $user = $this->readLine('Your Name', true);
294        $mail = $this->readLine('Your E-Mail', true);
295        $desc = $this->readLine('Short description');
296
297        $replacements = [
298            '@@AUTHOR_NAME@@' => $user,
299            '@@AUTHOR_MAIL@@' => $mail,
300            '@@PLUGIN_NAME@@' => $name,
301            '@@PLUGIN_DESC@@' => $desc,
302            '@@PLUGIN_TYPE@@' => $type,
303        ];
304        $replacements = $this->prepareReplacements($replacements);
305
306        $this->loadSkeleton('info.skel', $type . '.info.txt', $replacements);
307        $this->loadSkeleton('README.skel', 'README', $replacements); // fixme needs to be type specific
308        $this->loadSkeleton('LICENSE.skel', 'LICENSE', $replacements);
309
310        try {
311            $this->git('init');
312        } catch (CliException $e) {
313            $this->error($e->getMessage());
314        }
315
316        return 0;
317    }
318
319    /**
320     * Add test framework
321     *
322     * @param string $test Name of the Test to add
323     * @return int
324     */
325    protected function cmdAddTest($test = '')
326    {
327        $test = ucfirst(strtolower($test));
328
329        $replacements = $this->prepareReplacements(['@@TEST@@' => $test]);
330        $this->loadSkeleton('.github/workflows/phpTestLinux.skel', '.github/workflows/phpTestLinux.yml', $replacements);
331        if ($test) {
332            $this->loadSkeleton('_test/StandardTest.skel', '_test/' . $test . 'Test.php', $replacements);
333        } else {
334            $this->loadSkeleton('_test/GeneralTest.skel', '_test/GeneralTest.php', $replacements);
335        }
336
337        return 0;
338    }
339
340    /**
341     * Add configuration
342     *
343     * @return int
344     */
345    protected function cmdAddConf()
346    {
347        $replacements = $this->prepareReplacements();
348        $this->loadSkeleton('conf/default.skel', 'conf/default.php', $replacements);
349        $this->loadSkeleton('conf/metadata.skel', 'conf/metadata.php', $replacements);
350        if (is_dir('lang')) {
351            $this->loadSkeleton('lang/settings.skel', 'lang/en/settings.php', $replacements);
352        }
353
354        return 0;
355    }
356
357    /**
358     * Add language
359     *
360     * @return int
361     */
362    protected function cmdAddLang()
363    {
364        $replacements = $this->prepareReplacements();
365        $this->loadSkeleton('lang/lang.skel', 'lang/en/lang.php', $replacements);
366        if (is_dir('conf')) {
367            $this->loadSkeleton('lang/settings.skel', 'lang/en/settings.php', $replacements);
368        }
369
370        return 0;
371    }
372
373    /**
374     * Add another component to the plugin
375     *
376     * @param string $type
377     * @param string $component
378     */
379    protected function cmdAddComponent($type, $component = '')
380    {
381        $dir = fullpath(getcwd());
382        list($plugin, $extension) = $this->getTypedNameFromDir($dir);
383        if ($extension != 'plugin') throw  new CliException('Components can only be added to plugins');
384        if (!in_array($type, PluginController::PLUGIN_TYPES)) {
385            throw new CliException('Invalid type ' . $type);
386        }
387
388        if ($component) {
389            $path = $type . '/' . $component . '.php';
390            $class = $type . '_plugin_' . $plugin . '_' . $component;
391            $self = $plugin . '_' . $component;
392        } else {
393            $path = $type . '.php';
394            $class = $type . '_plugin_' . $plugin;
395            $self = $plugin;
396        }
397
398        $replacements = $this->actionReplacements();
399        $replacements['@@PLUGIN_COMPONENT_NAME@@'] = $class;
400        $replacements['@@SYNTAX_COMPONENT_NAME@@'] = $self;
401        $replacements = $this->prepareReplacements($replacements);
402        $this->loadSkeleton($type . '.skel', $path, $replacements);
403
404        return 0;
405    }
406
407    /**
408     * Generate a list of deleted files from git
409     *
410     * @link https://stackoverflow.com/a/6018049/172068
411     */
412    protected function cmdDeletedFiles()
413    {
414        if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git');
415
416        $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D');
417        $output = array_map('trim', $output);
418        $output = array_filter($output);
419        $output = array_unique($output);
420        $output = array_filter($output, function ($item) {
421            return !file_exists($item);
422        });
423        sort($output);
424
425        if (!count($output)) {
426            $this->info('No deleted files found');
427            return 0;
428        }
429
430        $content = "# This is a list of files that were present in previous releases\n" .
431            "# but were removed later. They should not exist in your installation.\n" .
432            join("\n", $output) . "\n";
433
434        file_put_contents('deleted.files', $content);
435        $this->success('written deleted.files');
436        return 0;
437    }
438
439    /**
440     * Remove files that shouldn't be here anymore
441     */
442    protected function rmObsolete()
443    {
444        $this->deleteFile('_test/general.test.php');
445        $this->deleteFile('.travis.yml');
446
447        return 0;
448    }
449
450    //endregion
451}
452