From 3b94911895389ad01d4c3223d5510cd007c9cf38 Mon Sep 17 00:00:00 2001 From: Anthony Axenov Date: Thu, 3 Aug 2023 12:48:59 +0800 Subject: [PATCH] First basic ready-to-use implementation --- .gitignore | 3 + LICENSE | 21 ++ README.md | 114 ++++++++ composer.json | 34 +++ composer.lock | 21 ++ pm-convert | 28 ++ .../CannotCreateDirectoryException.php | 11 + .../DirectoryIsNotReadableException.php | 11 + .../DirectoryIsNotWriteableException.php | 11 + .../DirectoryNotExistsException.php | 11 + src/Exporters/Abstract/AbstractConverter.php | 111 ++++++++ src/Exporters/Abstract/AbstractRequest.php | 164 +++++++++++ src/Exporters/ConvertFormat.php | 17 ++ src/Exporters/ConverterContract.php | 11 + src/Exporters/Curl/CurlConverter.php | 17 ++ src/Exporters/Curl/CurlRequest.php | 65 +++++ src/Exporters/Http/HttpConverter.php | 17 ++ src/Exporters/Http/HttpRequest.php | 53 ++++ src/Exporters/RequestContract.php | 18 ++ src/Exporters/Wget/WgetConverter.php | 17 ++ src/Exporters/Wget/WgetRequest.php | 63 +++++ src/FileSystem.php | 95 +++++++ src/Processor.php | 264 ++++++++++++++++++ 23 files changed, 1177 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100755 pm-convert create mode 100644 src/Exceptions/CannotCreateDirectoryException.php create mode 100644 src/Exceptions/DirectoryIsNotReadableException.php create mode 100644 src/Exceptions/DirectoryIsNotWriteableException.php create mode 100644 src/Exceptions/DirectoryNotExistsException.php create mode 100644 src/Exporters/Abstract/AbstractConverter.php create mode 100644 src/Exporters/Abstract/AbstractRequest.php create mode 100644 src/Exporters/ConvertFormat.php create mode 100644 src/Exporters/ConverterContract.php create mode 100644 src/Exporters/Curl/CurlConverter.php create mode 100644 src/Exporters/Curl/CurlRequest.php create mode 100644 src/Exporters/Http/HttpConverter.php create mode 100644 src/Exporters/Http/HttpRequest.php create mode 100644 src/Exporters/RequestContract.php create mode 100644 src/Exporters/Wget/WgetConverter.php create mode 100644 src/Exporters/Wget/WgetRequest.php create mode 100644 src/FileSystem.php create mode 100644 src/Processor.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9df34f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea +/.vscode +/vendor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..96e2e40 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Антон Аксенов (aka Anthony Axenov) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..71fddae --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# Postman collection converter + +Convert your Postman collections into different formats. + +Very fast. +Offline. +Without 3rd-party dependencies. + +These formats are supported for now: `http`, `curl`, `wget`. + +> This project was quickly written in my spare time to solve one exact problem in one NDA-project, so it may +> contain stupid errors and (for sure) doesn't cover all possible cases according to collection schema. +> So feel free to propose your improvements. + +## Supported features + +* [collection schema **v2.1**](https://schema.postman.com/json/collection/v2.1.0/collection.json); +* export one or several collections (or even whole directories) into one or all of formats supported at the same time; +* all headers (including disabled for `http`-format); +* `json` body (forces header `Content-Type` to `application/json`); +* `formdata` body (including disabled fields for `http`-format; forces header `Content-Type` to `multipart/form-data`) + +## Planned features + +- support as many as possible/necessary of authentication kinds (_currently no ones_); +- support as many as possible/necessary of body formats (_currently json and formdata_); +- documentation generation support (markdown) with responce examples (if present); +- maybe some another convert formats (like httpie or something...); +- replace `{{vars}}` from folder; +- replace `{{vars}}` from environment; +- performance measurement; +- better logging; +- tests, phpcs, psalm, etc.; +- web version. + +## Installation + +``` +composer global r axenov/pm-convert +``` + +Make sure your `~/.config/composer/vendor/bin` is in `$PATH` env: + +``` +echo $PATH | grep --color=auto 'composer' +# if not then execute this command and add it into ~/.profile: +export PATH="$PATH:~/.config/composer/vendor/bin" +``` + +## Usage + +``` +$ pm-convert --help +Postman collection converter +Usage: + ./pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS] + php pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS] + composer pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS] + ./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS] + +Possible ARGUMENTS: + -f, --file - a PATH to single collection located in PATH to convert from + -d, --dir - a directory with collections located in COLLECTION_FILEPATH to convert from + -o, --output - a directory OUTPUT_PATH to put results in + -p, --preserve - do not delete OUTPUT_PATH (if exists) + -h, --help - show this help message and exit + -v, --version - show version info and exit + +If both -c and -d are specified then only unique set of files will be converted. +-f or -d are required to be specified at least once, but each may be specified multiple times. +PATH must be a valid path to readable json-file or directory. +OUTPUT_PATH must be a valid path to writeable directory. +If -o is specified several times then only last one will be applied. + +Possible FORMATS: + --http - generate raw *.http files (default) + --curl - generate shell scripts with curl command + --wget - generate shell scripts with wget command +If no FORMATS specified then --http implied. +Any of FORMATS can be specified at the same time. + +Example: + ./pm-convert \ + -f ~/dir1/first.postman_collection.json \ + --directory ~/team \ + --file ~/dir2/second.postman_collection.json \ + -d ~/personal \ + -o ~/postman_export +``` +### Notice + +Make sure every (I mean _every_) collection (not collection file), its folders and/or requests has unique names. +If not, you can rename them in Postman or convert collections with similar names into different directories. +Otherwise converted files may be overwritten by each other. + +## License + +You can use, share and develop this project according to [MIT License](LICENSE). + +Postman is [protected legal trademark](https://www.postman.com/legal/trademark-policy/) of Postman, Inc. + +----- + +## Disclaimer + +I'm **not** affiliated with Postman, Inc. in any way. + +I'm just a backend developer who is forced to use this javascripted gigachad-shitmonster. + +So the goal of this project is to: +* take the data and its synchronization under own transparent control; +* easily migrate to something more RAM tolerant and productive, easier and free to use; +* get off the needle of the vendor lock, strict restrictions for teams and not to pay incredible $$$ for heavy useless WYSIWYGs; +* give YOU these opportunities. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4a1b864 --- /dev/null +++ b/composer.json @@ -0,0 +1,34 @@ +{ + "name": "axenov/pm-convert", + "type": "library", + "description": "Postman collection coverter", + "license": "MIT", + "homepage": "https://axenov.dev/", + "authors": [ + { + "name": "Anthony Axenov", + "homepage": "https://axenov.dev" + } + ], + "keywords": ["postman", "collection", "converter", "http", "wget", "curl", "api", "convert"], + "require": { + "php": "^8.1", + "ext-json": "*" + }, + "bin": [ + "pm-convert" + ], + "autoload": { + "psr-4": { + "PmConverter\\": "src\\" + } + }, + "scripts": { + "pm-convert": "@php ./pm-convert" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..f95283c --- /dev/null +++ b/composer.lock @@ -0,0 +1,21 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "b49bb03a97ad612632a24ebc59dfafcb", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": "^8.1", + "ext-json": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/pm-convert b/pm-convert new file mode 100755 index 0000000..9cf0a06 --- /dev/null +++ b/pm-convert @@ -0,0 +1,28 @@ +#!/usr/bin/env php +start(); +} catch (Exception $e) { + fwrite(STDERR, sprintf('ERROR: %s%s', $e->getMessage(), PHP_EOL)); + die(1); +} diff --git a/src/Exceptions/CannotCreateDirectoryException.php b/src/Exceptions/CannotCreateDirectoryException.php new file mode 100644 index 0000000..78be985 --- /dev/null +++ b/src/Exceptions/CannotCreateDirectoryException.php @@ -0,0 +1,11 @@ +outputPath = FileSystem::makeDir($outputPath); + $this->collection = $collection; + foreach ($collection->item as $item) { + $this->convertItem($item); + } + } + + /** + * @return string + */ + public function getOutputPath(): string + { + return $this->outputPath; + } + + /** + * @param object $item + * @return bool + */ + protected function isItemFolder(object $item): bool + { + return !empty($item->item) && empty($item->request); + } + + /** + * @throws Exception + */ + protected function convertItem(mixed $item): void + { + if ($this->isItemFolder($item)) { + static $dir_tree; + foreach ($item->item as $subitem) { + $dir_tree[] = $item->name; + $path = implode(DIRECTORY_SEPARATOR, $dir_tree); + if ($this->isItemFolder($subitem)) { + $this->convertItem($subitem); + } else { + $this->writeRequest($this->initRequest($subitem), $path); + } + array_pop($dir_tree); + } + } else { + $this->writeRequest($this->initRequest($item)); + } + } + + /** + * @param object $item + * @return RequestContract + */ + protected function initRequest(object $item): RequestContract + { + $request_class = static::REQUEST; + $result = new $request_class(); + $result->setName($item->name); + $result->setDescription($item->request?->description ?? null); + $result->setVerb($item->request->method); + $result->setUrl($item->request->url->raw); + $result->setHeaders($item->request->header); + if ($item->request->method !== 'GET' && !empty($item->request->body)) { + $result->setBody($item->request->body); + } + return $result; + } + + /** + * @param RequestContract $request + * @param string|null $subpath + * @return bool + * @throws Exception + */ + protected function writeRequest(RequestContract $request, string $subpath = null): bool + { + $filedir = sprintf('%s%s%s', $this->outputPath, DIRECTORY_SEPARATOR, $subpath); + $filedir = FileSystem::makeDir($filedir); + $filepath = sprintf('%s%s%s.%s', $filedir, DIRECTORY_SEPARATOR, $request->getName(), static::FILE_EXT); + return file_put_contents($filepath, (string)$request) > 0; + } +} diff --git a/src/Exporters/Abstract/AbstractRequest.php b/src/Exporters/Abstract/AbstractRequest.php new file mode 100644 index 0000000..365a2ab --- /dev/null +++ b/src/Exporters/Abstract/AbstractRequest.php @@ -0,0 +1,164 @@ +name = $name; + return $this; + } + + /** + * @return string + */ + public function getName(): string + { + return str_replace(DIRECTORY_SEPARATOR, '_', $this->name); + } + + /** + * @param string|null $description + * @return HttpRequest + */ + public function setDescription(?string $description): static + { + $this->description = $description; + return $this; + } + + /** + * @param string $verb + * @return HttpRequest + */ + public function setVerb(string $verb): static + { + $this->verb = $verb; + return $this; + } + + /** + * @param string $url + * @return HttpRequest + */ + public function setUrl(string $url): static + { + $this->url = $url; + return $this; + } + + /** + * @param object[]|null $headers + * @return $this + */ + public function setHeaders(?array $headers): static + { + foreach ($headers as $header) { + $this->headers[$header->key] = [ + 'value' => $header->value, + 'disabled' => $header?->disabled ?? false, + ]; + } + return $this; + } + + /** + * @param string $bodymode + * @return HttpRequest + */ + public function setBodymode(string $bodymode): static + { + $this->bodymode = $bodymode; + return $this; + } + + /** + * @param string $body + * @return HttpRequest + */ + public function setBody(object $body): static + { + $this->setBodymode($body->mode); + if (!empty($body->options) && $body->options->{$this->bodymode}->language === 'json') { + empty($this->headers['Content-Type']) && $this->setHeaders([ + (object)[ + 'key' => 'Content-Type', + 'value' => 'application/json', + 'disabled' => false, + ], + ]); + } + $body->mode === 'formdata' && $this->setHeaders([ + (object)[ + 'key' => 'Content-Type', + 'value' => 'multipart/form-data', + 'disabled' => false, + ], + ]); + $this->body = $body->{$body->mode}; + return $this; + } + + /** + * @return string + */ + abstract protected function prepareBody(): ?string; + + /** + * @return string + */ + abstract public function __toString(): string; +} diff --git a/src/Exporters/ConvertFormat.php b/src/Exporters/ConvertFormat.php new file mode 100644 index 0000000..9872819 --- /dev/null +++ b/src/Exporters/ConvertFormat.php @@ -0,0 +1,17 @@ +bodymode) { + case 'formdata': + $body = []; + foreach ($this->body as $data) { + $body[] = sprintf( + "%s\t--form '%s=%s' \ ", + isset($data->disabled) ? '# ' : '', + $data->key, + $data->type === 'file' ? "@$data->src" : $data->value + ); + } + return implode(PHP_EOL, $body); + default: + return $this->body; + } + } + + /** + * @return string + */ + public function __toString(): string + { + $output[] = '#!/bin/sh'; + if ($this->description) { + $output[] = '# ' . str_replace("\n", "\n# ", $this->description); + $output[] = ''; + } + $output[] = "curl \ "; + $output[] = "\t--http1.1 \ "; //TODO verb + $output[] = "\t--request $this->verb \ "; + $output[] = "\t--location $this->url \ "; + foreach ($this->headers as $header_key => $header) { + if ($header['disabled']) { + continue; + } + $output[] = sprintf("\t--header '%s=%s' \ ", $header_key, $header['value']); + } + if (!is_null($body = $this->prepareBody())) { + $output[] = match ($this->bodymode) { + 'formdata' => $body, + default => "\t--data '$body'", + }; + } + $output[] = rtrim(array_pop($output), '\ '); + return implode(PHP_EOL, $output); + } +} diff --git a/src/Exporters/Http/HttpConverter.php b/src/Exporters/Http/HttpConverter.php new file mode 100644 index 0000000..1a4ac42 --- /dev/null +++ b/src/Exporters/Http/HttpConverter.php @@ -0,0 +1,17 @@ +bodymode) { + case 'formdata': + $body = []; + foreach ($this->body as $data) { + $body[] = sprintf( + '%s%s=%s', + empty($data->disabled) ? '' : '# ', + $data->key, + $data->type === 'file' ? "$data->src" : $data->value + ); + } + return implode(PHP_EOL, $body); + default: + return $this->body; + } + } + + /** + * @return string + */ + public function __toString(): string + { + if ($this->description) { + $output[] = '# ' . str_replace("\n", "\n# ", $this->description); + $output[] = ''; + } + $output[] = "$this->verb $this->url $this->http"; + foreach ($this->headers as $header_key => $header) { + $output[] = sprintf('%s%s: %s', $header['disabled'] ? '# ' : '', $header_key, $header['value']); + } + $output[] = ''; + $output[] = (string)$this->prepareBody(); + return implode(PHP_EOL, $output); + } +} diff --git a/src/Exporters/RequestContract.php b/src/Exporters/RequestContract.php new file mode 100644 index 0000000..7fb4aa7 --- /dev/null +++ b/src/Exporters/RequestContract.php @@ -0,0 +1,18 @@ +bodymode) { + case 'formdata': + $lines = []; + foreach ($this->body as &$data) { + if ($data->type === 'file') { + continue; + } + $lines[$data->key] = $data->value; + } + $body[] = http_build_query($lines); + return implode(PHP_EOL, $body); + default: + return $this->body; + } + } + + /** + * @return string + */ + public function __toString(): string + { + $output[] = '#!/bin/sh'; + if ($this->description) { + $output[] = '# ' . str_replace("\n", "\n# ", $this->description); + $output[] = ''; + } + $output[] = 'wget \ '; + $output[] = "\t--no-check-certificate \ "; + $output[] = "\t--quiet \ "; + $output[] = "\t--timeout=0 \ "; + $output[] = "\t--method $this->verb \ "; + foreach ($this->headers as $header_key => $header) { + if ($header['disabled']) { + continue; + } + $output[] = sprintf("\t--header '%s=%s' \ ", $header_key, $header['value']); + } + if (!is_null($body = $this->prepareBody())) { + $output[] = "\t--body-data '$body' \ "; + } + $output[] = rtrim(array_pop($output), '\ '); + $output[] = "\t'$this->url'"; + return implode(PHP_EOL, $output); + } +} diff --git a/src/FileSystem.php b/src/FileSystem.php new file mode 100644 index 0000000..e325b36 --- /dev/null +++ b/src/FileSystem.php @@ -0,0 +1,95 @@ +argv) < 2) { + die(implode(PHP_EOL, $this->usage()) . PHP_EOL); + } + foreach ($this->argv as $idx => $arg) { + switch ($arg) { + case '-f': + case '--file': + $path = $this->argv[$idx + 1]; + if (empty($path) || !str_ends_with($path, '.json') || !file_exists($path) || !is_readable($path)) { + throw new InvalidArgumentException('a valid json-file path is expected for -f (--file)'); + } + $this->collectionPaths[] = $this->argv[$idx + 1]; + break; + case '-o': + case '--output': + if (empty($this->argv[$idx + 1])) { + throw new InvalidArgumentException('-o expected'); + } + $this->outputPath = $this->argv[$idx + 1]; + break; + case '-d': + case '--dir': + if (empty($this->argv[$idx + 1])) { + throw new InvalidArgumentException('a directory path is expected for -d (--dir)'); + } + $path = $this->argv[$idx + 1]; + if (FileSystem::checkDir($path)) { + $files = array_filter( + FileSystem::dirContents($path), + static fn($filename) => str_ends_with($filename, '.json') + ); + $this->collectionPaths = array_unique(array_merge($this?->collectionPaths ?? [], $files)); + } + break; + case '-p': + case '--preserve': + $this->preserveOutput = true; + break; + case '--http': + $this->formats[ConvertFormat::Http->name] = ConvertFormat::Http; + break; + case '--curl': + $this->formats[ConvertFormat::Curl->name] = ConvertFormat::Curl; + break; + case '--wget': + $this->formats[ConvertFormat::Wget->name] = ConvertFormat::Wget; + break; + case '-v': + case '--version': + die(implode(PHP_EOL, $this->version()) . PHP_EOL); + case '-h': + case '--help': + die(implode(PHP_EOL, $this->usage()) . PHP_EOL); + } + } + if (empty($this->formats)) { + $this->formats = [ConvertFormat::Http->name => ConvertFormat::Http]; + } + } + + /** + * @return void + * @throws CannotCreateDirectoryException + * @throws DirectoryIsNotWriteableException + * @throws DirectoryNotExistsException + * @throws DirectoryIsNotReadableException + */ + protected function initOutputDirectory(): void + { + if (isset($this?->outputPath) && !$this->preserveOutput) { + FileSystem::removeDir($this->outputPath); + } + FileSystem::makeDir($this->outputPath); + } + + /** + * Initializes converters according to choosen formats + * + * @return void + */ + protected function initConverters(): void + { + foreach ($this->formats as $type) { + $this->converters[$type->name] = new $type->value($this->preserveOutput); + } + } + + /** + * @throws JsonException + */ + protected function initCollections(): void + { + foreach ($this->collectionPaths as $collectionPath) { + $content = file_get_contents(FileSystem::normalizePath($collectionPath)); + $content = json_decode($content, flags: JSON_THROW_ON_ERROR); + if (!property_exists($content, 'collection') || empty($content?->collection)) { + throw new JsonException("not a valid collection: $collectionPath"); + } + $this->collections[$content->collection->info->name] = $content->collection; + } + } + + /** + * Begins a conversion + * + * @throws Exception + */ + public function start(): void + { + $this->parseArgs(); + $this->initOutputDirectory(); + $this->initConverters(); + $this->initCollections(); + print(implode(PHP_EOL, array_merge($this->version(), $this->copyright())) . PHP_EOL . PHP_EOL); + foreach ($this->collections as $collectionName => $collection) { + print("Converting '$collectionName':" . PHP_EOL); + foreach ($this->converters as $type => $exporter) { + print("\t-> " . strtolower($type)); + $outputPath = sprintf('%s%s%s', $this->outputPath, DIRECTORY_SEPARATOR, $collectionName); + $exporter->convert($collection, $outputPath); + printf("\t- OK: %s%s", $exporter->getOutputPath(), PHP_EOL); + } + } + } + + /** + * @return string[] + */ + protected function version(): array + { + return ["Postman collection converter v" . self::VERSION]; + } + + /** + * @return string[] + */ + protected function copyright(): array + { + return [ + 'Anthony Axenov (c) ' . date('Y') . ", MIT license", + 'https://git.axenov.dev/anthony/pm-convert' + ]; + } + + /** + * @return array + */ + protected function usage(): array + { + return array_merge($this->version(), [ + 'Usage:', + "\t./pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", + "\tphp pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", + "\tcomposer pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", + "\t./vendor/bin/pm-convert -f|-d PATH -o OUTPUT_PATH [ARGUMENTS] [FORMATS]", + '', + 'Possible ARGUMENTS:', + "\t-f, --file - a PATH to single collection located in PATH to convert from", + "\t-d, --dir - a directory with collections located in COLLECTION_FILEPATH to convert from", + "\t-o, --output - a directory OUTPUT_PATH to put results in", + "\t-p, --preserve - do not delete OUTPUT_PATH (if exists)", + "\t-h, --help - show this help message and exit", + "\t-v, --version - show version info and exit", + '', + 'If no ARGUMENTS passed then --help implied.', + 'If both -c and -d are specified then only unique set of files will be converted.', + '-f or -d are required to be specified at least once, but each may be specified multiple times.', + 'PATH must be a valid path to readable json-file or directory.', + 'OUTPUT_PATH must be a valid path to writeable directory.', + 'If -o is specified several times then only last one will be used.', + '', + 'Possible FORMATS:', + "\t--http - generate raw *.http files (default)", + "\t--curl - generate shell scripts with curl command", + "\t--wget - generate shell scripts with wget command", + 'If no FORMATS specified then --http implied.', + 'Any of FORMATS can be specified at the same time.', + '', + 'Example:', + " ./pm-convert \ ", + " -f ~/dir1/first.postman_collection.json \ ", + " --directory ~/team \ ", + " --file ~/dir2/second.postman_collection.json \ ", + " -d ~/personal \ ", + " -o ~/postman_export ", + "", + ], $this->copyright()); + } +}