├── .actrc ├── .editorconfig ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── code-quality.yml │ ├── regenerate-readme.yml │ └── testing.yml ├── .gitignore ├── LICENSE ├── README.md ├── behat.yml ├── composer.json ├── dist-archive-command.php ├── features ├── dist-archive.feature └── distignore.feature ├── phpcs.xml.dist ├── src └── Dist_Archive_Command.php └── wp-cli.yml /.actrc: -------------------------------------------------------------------------------- 1 | # Configuration file for nektos/act. 2 | # See https://github.com/nektos/act#configuration 3 | -P ubuntu-latest=shivammathur/node:latest 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | # From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions. 8 | 9 | root = true 10 | 11 | [*] 12 | charset = utf-8 13 | end_of_line = lf 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | indent_style = tab 17 | 18 | [{*.yml,*.feature,.jshintrc,*.json}] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | 25 | [{*.txt,wp-config-sample.php}] 26 | end_of_line = crlf 27 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wp-cli/committers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | labels: 9 | - scope:distribution 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | open-pull-requests-limit: 10 15 | labels: 16 | - scope:distribution 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality Checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | 10 | jobs: 11 | code-quality: 12 | uses: wp-cli/.github/.github/workflows/reusable-code-quality.yml@main 13 | -------------------------------------------------------------------------------- /.github/workflows/regenerate-readme.yml: -------------------------------------------------------------------------------- 1 | name: Regenerate README file 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths-ignore: 10 | - "features/**" 11 | - "README.md" 12 | 13 | jobs: 14 | regenerate-readme: 15 | uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | schedule: 10 | - cron: '17 1 * * *' # Run every day on a seemly random time. 11 | 12 | jobs: 13 | test: 14 | uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main 15 | with: 16 | minimum-php: '7.2' 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | composer.lock 3 | node_modules/ 4 | vendor/ 5 | *.log 6 | phpunit.xml 7 | phpcs.xml 8 | .phpcs.xml 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2011-2018 WP-CLI Development Group (https://github.com/wp-cli/dist-archive-command/contributors) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wp-cli/dist-archive-command 2 | =========================== 3 | 4 | Create a distribution .zip or .tar.gz based on a plugin or theme's .distignore file. 5 | 6 | [![Testing](https://github.com/wp-cli/dist-archive-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/dist-archive-command/actions/workflows/testing.yml) 7 | 8 | Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support) 9 | 10 | ## Using 11 | 12 | ~~~ 13 | wp dist-archive [] [--create-target-dir] [--force] [--plugin-dirname=] [--format=] [--filename-format=] 14 | ~~~ 15 | 16 | For a plugin in a directory 'wp-content/plugins/hello-world', this command 17 | creates a distribution archive 'wp-content/plugins/hello-world.zip'. 18 | 19 | You can specify files or directories you'd like to exclude from the archive 20 | with a .distignore file in your project repository: 21 | 22 | ``` 23 | .distignore 24 | .editorconfig 25 | .git 26 | .gitignore 27 | .travis.yml 28 | circle.yml 29 | ``` 30 | 31 | Use one distribution archive command for many projects, instead of a bash 32 | script in each project. 33 | 34 | **OPTIONS** 35 | 36 | 37 | Path to the project that includes a .distignore file. 38 | 39 | [] 40 | Path and optional file name for the distribution archive. 41 | If only a path is provided, the file name defaults to the project directory name plus the version, if discoverable. 42 | Also, if only a path is given, the directory that it points to has to already exist for the command to function correctly. 43 | 44 | [--create-target-dir] 45 | Automatically create the target directory as needed. 46 | 47 | [--force] 48 | Forces overwriting of the archive file if it already exists. 49 | 50 | [--plugin-dirname=] 51 | Set the archive extract directory name. Defaults to project directory name. 52 | 53 | [--format=] 54 | Choose the format for the archive. 55 | --- 56 | default: zip 57 | options: 58 | - zip 59 | - targz 60 | --- 61 | 62 | [--filename-format=] 63 | Use a custom format for archive filename. Available substitutions: {name}, {version}. 64 | This is ignored if the parameter is provided or the version cannot be determined. 65 | --- 66 | default: "{name}.{version}" 67 | --- 68 | 69 | ## Installing 70 | 71 | Installing this package requires WP-CLI v2 or greater. Update to the latest stable release with `wp cli update`. 72 | 73 | Once you've done so, you can install the latest stable version of this package with: 74 | 75 | ```bash 76 | wp package install wp-cli/dist-archive-command:@stable 77 | ``` 78 | 79 | To install the latest development version of this package, use the following command instead: 80 | 81 | ```bash 82 | wp package install wp-cli/dist-archive-command:dev-main 83 | ``` 84 | 85 | ## Contributing 86 | 87 | We appreciate you taking the initiative to contribute to this project. 88 | 89 | Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation. 90 | 91 | For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines. 92 | 93 | ### Reporting a bug 94 | 95 | Think you’ve found a bug? We’d love for you to help us get it fixed. 96 | 97 | Before you create a new issue, you should [search existing issues](https://github.com/wp-cli/dist-archive-command/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version. 98 | 99 | Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/wp-cli/dist-archive-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/). 100 | 101 | ### Creating a pull request 102 | 103 | Want to contribute a new feature? Please first [open a new issue](https://github.com/wp-cli/dist-archive-command/issues/new) to discuss whether the feature is a good fit for the project. 104 | 105 | Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally. 106 | 107 | ## Support 108 | 109 | GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support 110 | 111 | 112 | *This README.md is generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). To suggest changes, please submit a pull request against the corresponding part of the codebase.* 113 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | default: 4 | contexts: 5 | - WP_CLI\Tests\Context\FeatureContext 6 | paths: 7 | - features 8 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-cli/dist-archive-command", 3 | "type": "wp-cli-package", 4 | "description": "Create a distribution .zip or .tar.gz based on a plugin or theme's .distignore file.", 5 | "homepage": "https://github.com/wp-cli/dist-archive-command/", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Daniel Bachhuber", 10 | "email": "daniel@runcommand.io", 11 | "homepage": "https://runcommand.io" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=7.2", 16 | "wp-cli/wp-cli": "^2", 17 | "inmarelibero/gitignore-checker": "^1.0.4" 18 | }, 19 | "require-dev": { 20 | "wp-cli/wp-cli-tests": "^4", 21 | "wp-cli/scaffold-command": "^2", 22 | "wp-cli/extension-command": "^2" 23 | }, 24 | "extra": { 25 | "commands": [ 26 | "dist-archive" 27 | ], 28 | "readme": { 29 | "shields": [ 30 | "[![Testing](https://github.com/wp-cli/dist-archive-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/dist-archive-command/actions/workflows/testing.yml)" 31 | ] 32 | } 33 | }, 34 | "autoload": { 35 | "classmap": [ 36 | "src/" 37 | ], 38 | "files": [ 39 | "dist-archive-command.php" 40 | ] 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true, 44 | "scripts": { 45 | "behat": "run-behat-tests", 46 | "behat-rerun": "rerun-behat-tests", 47 | "lint": "run-linter-tests", 48 | "phpcs": "run-phpcs-tests", 49 | "phpcbf": "run-phpcbf-cleanup", 50 | "phpunit": "run-php-unit-tests", 51 | "prepare-tests": "install-package-tests", 52 | "test": [ 53 | "@lint", 54 | "@phpcs", 55 | "@phpunit", 56 | "@behat" 57 | ] 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "dealerdirect/phpcodesniffer-composer-installer": true, 62 | "johnpbloch/wordpress-core-installer": true 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /dist-archive-command.php: -------------------------------------------------------------------------------- 1 | function () { 17 | if ( version_compare( PHP_VERSION, '7.1', '<' ) ) { 18 | WP_CLI::error( 'PHP 7.1 or later is required.' ); 19 | } 20 | }, 21 | ] 22 | ); 23 | -------------------------------------------------------------------------------- /features/dist-archive.feature: -------------------------------------------------------------------------------- 1 | @require-php-7.1 2 | Feature: Generate a distribution archive of a project 3 | 4 | Scenario: Generates a ZIP archive by default 5 | Given a WP install 6 | 7 | When I run `wp scaffold plugin hello-world` 8 | Then the wp-content/plugins/hello-world directory should exist 9 | And the wp-content/plugins/hello-world/hello-world.php file should exist 10 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 11 | And the wp-content/plugins/hello-world/bin directory should exist 12 | 13 | When I run `wp dist-archive wp-content/plugins/hello-world` 14 | Then STDOUT should match /^Success: Created hello-world.0.1.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 15 | And STDERR should be empty 16 | And the wp-content/plugins/hello-world.0.1.0.zip file should exist 17 | 18 | When I run `wp plugin delete hello-world` 19 | Then the wp-content/plugins/hello-world directory should not exist 20 | 21 | When I run `wp plugin install wp-content/plugins/hello-world.0.1.0.zip` 22 | Then the wp-content/plugins/hello-world directory should exist 23 | And the wp-content/plugins/hello-world/hello-world.php file should exist 24 | And the wp-content/plugins/hello-world/.circleci/config.yml file should not exist 25 | And the wp-content/plugins/hello-world/bin directory should not exist 26 | 27 | Scenario: Generates a tarball archive with a flag 28 | Given a WP install 29 | 30 | When I run `wp scaffold plugin hello-world` 31 | Then the wp-content/plugins/hello-world directory should exist 32 | And the wp-content/plugins/hello-world/hello-world.php file should exist 33 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 34 | And the wp-content/plugins/hello-world/bin directory should exist 35 | 36 | When I run `wp dist-archive wp-content/plugins/hello-world --format=targz` 37 | Then STDOUT should match /^Success: Created hello-world.0.1.0.tar.gz \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 38 | And STDERR should be empty 39 | And the wp-content/plugins/hello-world.0.1.0.tar.gz file should exist 40 | 41 | When I run `wp plugin delete hello-world` 42 | Then the wp-content/plugins/hello-world directory should not exist 43 | 44 | When I try `cd wp-content/plugins/ && tar -zxvf hello-world.0.1.0.tar.gz` 45 | Then the wp-content/plugins/hello-world directory should exist 46 | And the wp-content/plugins/hello-world/hello-world.php file should exist 47 | And the wp-content/plugins/hello-world/.circleci/config.yml file should not exist 48 | And the wp-content/plugins/hello-world/bin directory should not exist 49 | 50 | Scenario: Generate a ZIP archive with a custom name 51 | Given a WP install 52 | 53 | When I run `wp scaffold plugin hello-world` 54 | Then the wp-content/plugins/hello-world directory should exist 55 | And the wp-content/plugins/hello-world/hello-world.php file should exist 56 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 57 | And the wp-content/plugins/hello-world/bin directory should exist 58 | 59 | When I run `wp dist-archive wp-content/plugins/hello-world hello-world.zip` 60 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 61 | And the wp-content/plugins/hello-world.zip file should exist 62 | And the wp-content/plugins/hello-world.0.1.0.zip file should not exist 63 | 64 | Scenario: Generate a ZIP archive to a relative path without specifying the filename 65 | Given a WP install 66 | 67 | When I run `wp scaffold plugin hello-world` 68 | Then the wp-content/plugins/hello-world directory should exist 69 | And the wp-content/plugins/hello-world/hello-world.php file should exist 70 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 71 | And the wp-content/plugins/hello-world/bin directory should exist 72 | 73 | When I run `wp dist-archive wp-content/plugins/hello-world wp-content` 74 | Then STDOUT should match /^Success: Created hello-world.0.1.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 75 | And the wp-content/hello-world.0.1.0.zip file should exist 76 | And the wp-content/plugins/hello-world.0.1.0.zip file should not exist 77 | 78 | Scenario: Generate a ZIP archive to a relative path with a specified filename 79 | Given a WP install 80 | 81 | When I run `wp scaffold plugin hello-world` 82 | Then the wp-content/plugins/hello-world directory should exist 83 | And the wp-content/plugins/hello-world/hello-world.php file should exist 84 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 85 | And the wp-content/plugins/hello-world/bin directory should exist 86 | 87 | When I run `mkdir subdir` 88 | Then the subdir directory should exist 89 | 90 | When I run `wp dist-archive wp-content/plugins/hello-world ./subdir/hello-world.zip` 91 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 92 | And STDERR should be empty 93 | And the {RUN_DIR}/subdir/hello-world.zip file should exist 94 | 95 | Scenario: Generate a ZIP archive to an absolute path without specifying the filename 96 | Given a WP install 97 | 98 | When I run `wp scaffold plugin hello-world` 99 | Then the wp-content/plugins/hello-world directory should exist 100 | And the wp-content/plugins/hello-world/hello-world.php file should exist 101 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 102 | And the wp-content/plugins/hello-world/bin directory should exist 103 | 104 | When I run `wp dist-archive wp-content/plugins/hello-world {RUN_DIR}/wp-content/` 105 | Then STDOUT should match /^Success: Created hello-world.0.1.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 106 | And STDERR should be empty 107 | And the {RUN_DIR}/wp-content/hello-world.0.1.0.zip file should exist 108 | 109 | Scenario: Generate a ZIP archive using version number in composer.json 110 | Given an empty directory 111 | And a foo/.distignore file: 112 | """ 113 | .gitignore 114 | .distignore 115 | features/ 116 | """ 117 | And a foo/features/sample.feature file: 118 | """ 119 | Testing 120 | """ 121 | And a foo/composer.json file: 122 | """ 123 | { 124 | "name": "runcommand/profile", 125 | "description": "Quickly identify what's slow with WordPress.", 126 | "homepage": "https://runcommand.io/wp/profile/", 127 | "version": "0.2.0-alpha" 128 | } 129 | """ 130 | 131 | When I run `wp dist-archive foo` 132 | Then STDOUT should match /^Success: Created foo.0.2.0-alpha.zip \(Size: \d* [a-zA-Z]{1,3}\)$/ 133 | And the foo.0.2.0-alpha.zip file should exist 134 | 135 | When I run `rm -rf foo` 136 | Then the foo directory should not exist 137 | 138 | When I run `unzip foo.0.2.0-alpha.zip` 139 | Then the foo directory should exist 140 | And the foo/composer.json file should exist 141 | And the foo/.distignore file should not exist 142 | And the foo/features/sample.feature file should not exist 143 | 144 | Scenario: Create directories automatically if requested 145 | Given a WP install 146 | 147 | When I run `wp scaffold plugin hello-world` 148 | Then the wp-content/plugins/hello-world directory should exist 149 | And the wp-content/plugins/hello-world/hello-world.php file should exist 150 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 151 | And the wp-content/plugins/hello-world/bin directory should exist 152 | 153 | When I try `wp dist-archive wp-content/plugins/hello-world {RUN_DIR}/some/nested/folder/hello-world.zip` 154 | Then STDERR should contain: 155 | """ 156 | Error: Target directory does not exist 157 | """ 158 | 159 | When I run `wp dist-archive --create-target-dir wp-content/plugins/hello-world {RUN_DIR}/some/nested/folder/hello-world.zip` 160 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 161 | And STDERR should be empty 162 | And the {RUN_DIR}/some/nested/folder/hello-world.zip file should exist 163 | 164 | Scenario: Allow specifying the current directory for input using dot 165 | Given a WP install 166 | 167 | When I run `wp scaffold plugin hello-world` 168 | Then the wp-content/plugins/hello-world directory should exist 169 | And the wp-content/plugins/hello-world/hello-world.php file should exist 170 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 171 | And the wp-content/plugins/hello-world/bin directory should exist 172 | 173 | When I run `wp dist-archive . {RUN_DIR}/hello-world.zip` from 'wp-content/plugins/hello-world' 174 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 175 | And STDERR should be empty 176 | And the {RUN_DIR}/hello-world.zip file should exist 177 | 178 | Scenario: Use plugin parent directory for output unless otherwise specified 179 | Given a WP install 180 | 181 | When I run `wp scaffold plugin hello-world` 182 | Then the wp-content/plugins/hello-world directory should exist 183 | And the wp-content/plugins/hello-world/hello-world.php file should exist 184 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 185 | And the wp-content/plugins/hello-world/bin directory should exist 186 | 187 | When I run `wp dist-archive . hello-world.zip` from 'wp-content/plugins/hello-world' 188 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 189 | And STDERR should be empty 190 | And the {RUN_DIR}/wp-content/plugins/hello-world.zip file should exist 191 | 192 | Scenario: Use current directory for output when specified 193 | Given a WP install 194 | 195 | When I run `wp scaffold plugin hello-world` 196 | Then the wp-content/plugins/hello-world directory should exist 197 | And the wp-content/plugins/hello-world/hello-world.php file should exist 198 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 199 | And the wp-content/plugins/hello-world/bin directory should exist 200 | 201 | When I run `wp dist-archive . ./hello-world.zip` from 'wp-content/plugins/hello-world' 202 | Then STDOUT should match /^Success: Created hello-world.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 203 | And STDERR should be empty 204 | And the {RUN_DIR}/wp-content/plugins/hello-world/hello-world.zip file should exist 205 | 206 | Scenario: Allow specifying the current directory without filename for output using dot 207 | Given a WP install 208 | 209 | When I run `wp scaffold plugin hello-world` 210 | Then the wp-content/plugins/hello-world directory should exist 211 | And the wp-content/plugins/hello-world/hello-world.php file should exist 212 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 213 | And the wp-content/plugins/hello-world/bin directory should exist 214 | 215 | When I run `wp dist-archive wp-content/plugins/hello-world .` 216 | Then STDOUT should match /^Success: Created hello-world.0.1.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 217 | And STDERR should be empty 218 | And the {RUN_DIR}/hello-world.0.1.0.zip file should exist 219 | 220 | Scenario: Generates an archive with another name using the plugin-dirname flag 221 | Given a WP install 222 | 223 | When I run `wp scaffold plugin hello-world` 224 | Then the wp-content/plugins/hello-world directory should exist 225 | And the wp-content/plugins/hello-world/hello-world.php file should exist 226 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 227 | And the wp-content/plugins/hello-world/bin directory should exist 228 | 229 | When I run `wp dist-archive wp-content/plugins/hello-world --plugin-dirname=foobar-world` 230 | Then STDOUT should match /^Success: Created foobar-world.0.1.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 231 | And STDERR should be empty 232 | And the wp-content/plugins/foobar-world.0.1.0.zip file should exist 233 | 234 | When I run `wp plugin delete hello-world` 235 | Then the wp-content/plugins/hello-world directory should not exist 236 | 237 | When I run `wp plugin install wp-content/plugins/foobar-world.0.1.0.zip` 238 | Then the wp-content/plugins/foobar-world directory should exist 239 | And the wp-content/plugins/foobar-world/hello-world.php file should exist 240 | 241 | Scenario: Finds the version tag even if ill-formed 242 | Given a WP install 243 | 244 | When I run `wp scaffold plugin hello-world` 245 | Then the wp-content/plugins/hello-world directory should exist 246 | And the wp-content/plugins/hello-world/hello-world.php file should exist 247 | And the wp-content/plugins/hello-world/.circleci/config.yml file should exist 248 | And the wp-content/plugins/hello-world/bin directory should exist 249 | 250 | When I run `awk '{sub("\\* Version","Version",$0); print}' {RUN_DIR}/wp-content/plugins/hello-world/hello-world.php > hello-world.tmp && mv hello-world.tmp {RUN_DIR}/wp-content/plugins/hello-world/hello-world.php` 251 | Then STDERR should be empty 252 | When I run `awk '{sub("0.1.0","0.2.0",$0); print}' {RUN_DIR}/wp-content/plugins/hello-world/hello-world.php > hello-world.tmp && mv hello-world.tmp {RUN_DIR}/wp-content/plugins/hello-world/hello-world.php` 253 | Then STDERR should be empty 254 | 255 | When I run `wp dist-archive wp-content/plugins/hello-world` 256 | Then STDOUT should match /^Success: Created hello-world.0.2.0.zip \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 257 | And STDERR should be empty 258 | And the wp-content/plugins/hello-world.0.2.0.zip file should exist 259 | 260 | When I run `wp plugin delete hello-world` 261 | Then the wp-content/plugins/hello-world directory should not exist 262 | 263 | When I run `wp plugin install wp-content/plugins/hello-world.0.2.0.zip` 264 | Then the wp-content/plugins/hello-world directory should exist 265 | And the wp-content/plugins/hello-world/hello-world.php file should exist 266 | And the wp-content/plugins/hello-world/.circleci/config.yml file should not exist 267 | And the wp-content/plugins/hello-world/bin directory should not exist 268 | 269 | # This test does not work with SQLite because it wipes wp-content 270 | # but SQLite requires an integration plugin & drop-in to work. 271 | @require-mysql 272 | Scenario: Avoids recursive symlink 273 | Given a WP install in wordpress 274 | And a .distignore file: 275 | """ 276 | wp-content 277 | wordpress 278 | """ 279 | 280 | When I run `mkdir -p wp-content/plugins` 281 | Then STDERR should be empty 282 | 283 | When I run `rm -rf wordpress/wp-content` 284 | Then STDERR should be empty 285 | 286 | When I run `ln -s {RUN_DIR}/wp-content {RUN_DIR}/wordpress/wp-content` 287 | Then STDERR should be empty 288 | 289 | When I run `wp scaffold plugin hello-world --path=wordpress` 290 | Then the wp-content/plugins/hello-world directory should exist 291 | And the wp-content/plugins/hello-world/hello-world.php file should exist 292 | 293 | When I run `mv wp-content/plugins/hello-world/hello-world.php .` 294 | Then STDERR should be empty 295 | 296 | When I run `rm -rf wp-content/plugins/hello-world` 297 | Then STDERR should be empty 298 | 299 | When I run `ln -s {RUN_DIR} {RUN_DIR}/wp-content/plugins/hello-world` 300 | Then STDERR should be empty 301 | And the wp-content/plugins/hello-world/hello-world.php file should exist 302 | 303 | When I run `wp dist-archive . --plugin-dirname=$(basename "{RUN_DIR}")` 304 | Then STDERR should be empty 305 | 306 | Scenario: Warns but continues when no distignore file is present 307 | Given an empty directory 308 | And a test-plugin/test-plugin.php file: 309 | """ 310 | foo/.distignore` 488 | And I try `wp dist-archive foo --force` 489 | Then the foo.zip file should exist 490 | 491 | When I try `zipinfo -1 foo.zip` 492 | Then STDOUT should contain: 493 | """ 494 | foo/bar.txt 495 | """ 496 | And STDOUT should not contain: 497 | """ 498 | foo/foo.txt 499 | """ 500 | 501 | Scenario: Removes existing files in the tarball 502 | Given an empty directory 503 | And a foo/.distignore file: 504 | """ 505 | """ 506 | And a foo/foo.txt file: 507 | """ 508 | Hello Foo 509 | """ 510 | And a foo/bar.txt file: 511 | """ 512 | Hello Bar 513 | """ 514 | 515 | When I try `wp dist-archive foo --format=targz` 516 | Then the foo.tar.gz file should exist 517 | 518 | When I try `tar -tf foo.tar.gz` 519 | Then STDOUT should contain: 520 | """ 521 | foo/bar.txt 522 | """ 523 | And STDOUT should contain: 524 | """ 525 | foo/foo.txt 526 | """ 527 | 528 | When I run `echo "foo.txt" > foo/.distignore` 529 | And I try `wp dist-archive foo --format=targz --force` 530 | Then the foo.tar.gz file should exist 531 | 532 | When I try `tar -tf foo.tar.gz` 533 | Then STDOUT should contain: 534 | """ 535 | foo/bar.txt 536 | """ 537 | And STDOUT should not contain: 538 | """ 539 | foo/foo.txt 540 | """ 541 | -------------------------------------------------------------------------------- /features/distignore.feature: -------------------------------------------------------------------------------- 1 | # The "Examples" dataprovider is needed due to differences in zip and tar. 2 | @require-php-7.1 3 | Feature: Generate a distribution archive of a project with .distignore 4 | 5 | Scenario: Ignores backup files with a period and tilde 6 | Given an empty directory 7 | And a foo/.distignore file: 8 | """ 9 | .*~ 10 | """ 11 | And a foo/test.php file: 12 | """ 13 | ` 67 | Then STDOUT should match /^Success: Created foo. \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 68 | And the foo. file should exist 69 | 70 | When I run `rm -rf foo` 71 | Then the foo directory should not exist 72 | 73 | When I try ` foo.` 74 | Then the foo directory should exist 75 | And the foo/test.php file should exist 76 | And the foo/test-dir/test.php file should exist 77 | And the foo/test-dir/.DS_Store file should not exist 78 | 79 | Examples: 80 | | format | extension | extract | 81 | | zip | zip | unzip | 82 | | targz | tar.gz | tar -zxvf | 83 | 84 | Scenario Outline: Ignores .git folder 85 | Given an empty directory 86 | And a foo/.distignore file: 87 | """ 88 | .git 89 | """ 90 | And a foo/.git/version.control file: 91 | """ 92 | history 93 | """ 94 | And a foo/.git/subfolder/version.control file: 95 | """ 96 | history 97 | """ 98 | And a foo/plugin.php file: 99 | """ 100 | ` 107 | Then STDOUT should match /^Success: Created foo. \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 108 | And the foo. file should exist 109 | 110 | When I run `rm -rf foo` 111 | Then the foo directory should not exist 112 | 113 | When I try ` foo.` 114 | Then the foo directory should exist 115 | And the foo/plugin.php file should exist 116 | And the foo/.git directory should not exist 117 | And the foo/.git/subfolder directory should not exist 118 | And the foo/.git/version.control file should not exist 119 | And the foo/.git/subfolder/version.control file should not exist 120 | 121 | Examples: 122 | | format | extension | extract | 123 | | zip | zip | unzip | 124 | | targz | tar.gz | tar -zxvf | 125 | 126 | Scenario Outline: Ignores files specified with absolute path and not similarly named files 127 | Given an empty directory 128 | And a foo/.distignore file: 129 | """ 130 | /maybe-ignore-me.txt 131 | """ 132 | And a foo/test.php file: 133 | """ 134 | --plugin-dirname=` 156 | Then STDOUT should match /^Success: Created . \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 157 | And the . file should exist 158 | 159 | When I run `rm -rf foo` 160 | Then the foo directory should not exist 161 | 162 | When I run `rm -rf ` 163 | Then the directory should not exist 164 | 165 | When I try ` .` 166 | Then the directory should exist 167 | And the /test.php file should exist 168 | And the /test-dir/test.php file should exist 169 | And the /maybe-ignore-me.txt file should not exist 170 | And the /test-dir/maybe-ignore-me.txt file should exist 171 | And the /test-dir/foo/maybe-ignore-me.txt file should exist 172 | 173 | Examples: 174 | | format | extension | extract | plugin-dirname | 175 | | zip | zip | unzip | foo | 176 | | targz | tar.gz | tar -zxvf | foo | 177 | | zip | zip | unzip | bar | 178 | | targz | tar.gz | tar -zxvf | bar2 | 179 | 180 | Scenario Outline: Correctly ignores hidden files when specified in distignore 181 | Given an empty directory 182 | And a foo/.distignore file: 183 | """ 184 | .* 185 | """ 186 | And a foo/.hidden file: 187 | """ 188 | Ignore 189 | """ 190 | And a foo/test-dir/.hidden file: 191 | """ 192 | Ignore 193 | """ 194 | And a foo/not.hidden file: 195 | """ 196 | Do not ignore 197 | """ 198 | And a foo/test-dir/not.hidden file: 199 | """ 200 | Do not ignore 201 | """ 202 | 203 | When I run `wp dist-archive foo --format= --plugin-dirname=` 204 | Then STDOUT should match /^Success: Created . \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 205 | And the . file should exist 206 | 207 | When I run `rm -rf foo` 208 | Then the foo directory should not exist 209 | 210 | When I run `rm -rf ` 211 | Then the directory should not exist 212 | 213 | When I try ` .` 214 | Then the directory should exist 215 | And the /.hidden file should not exist 216 | And the /not.hidden file should exist 217 | And the /test-dir/.hidden file should not exist 218 | And the /test-dir/not.hidden file should exist 219 | 220 | Examples: 221 | | format | extension | extract | plugin-dirname | 222 | | zip | zip | unzip | foo | 223 | | targz | tar.gz | tar -zxvf | foo | 224 | | zip | zip | unzip | bar3 | 225 | | targz | tar.gz | tar -zxvf | bar4 | 226 | 227 | Scenario Outline: Ignores files with exact match and not similarly named files 228 | Given an empty directory 229 | And a foo/.distignore file: 230 | """ 231 | ignore-me.js 232 | """ 233 | And a foo/test.php file: 234 | """ 235 | --plugin-dirname=` 248 | Then STDOUT should match /^Success: Created . \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 249 | And the . file should exist 250 | 251 | When I run `rm -rf foo` 252 | Then the foo directory should not exist 253 | 254 | When I run `rm -rf ` 255 | Then the directory should not exist 256 | 257 | When I try ` .` 258 | Then the directory should exist 259 | And the /test.php file should exist 260 | And the /ignore-me.json file should exist 261 | And the /ignore-me.js file should not exist 262 | 263 | Examples: 264 | | format | extension | extract | plugin-dirname | 265 | | zip | zip | unzip | foo | 266 | | targz | tar.gz | tar -zxvf | foo | 267 | | zip | zip | unzip | bar | 268 | | targz | tar.gz | tar -zxvf | bar2 | 269 | 270 | Scenario Outline: Ignores files in ignored directory except subdirectory excluded from exclusion: `!/frontend/build/` 271 | # @see https://github.com/wp-cli/dist-archive-command/issues/44#issue-917541953 272 | Given an empty directory 273 | And a foo/test.php file: 274 | """ 275 | --plugin-dirname=` 293 | Then STDOUT should match /^Success: Created . \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 294 | And the . file should exist 295 | 296 | When I run `mv foo sourcefoo` 297 | Then the foo directory should not exist 298 | 299 | When I run `rm -rf ` 300 | Then the directory should not exist 301 | 302 | When I try ` .` 303 | Then the directory should exist 304 | And the /test.php file should exist 305 | And the /frontend/test.ts file should not exist 306 | And the /frontend/build/test.js file should exist 307 | 308 | Examples: 309 | | format | extension | extract | plugin-dirname | 310 | | zip | zip | unzip | foo | 311 | | targz | tar.gz | tar -zxvf | foo | 312 | | zip | zip | unzip | bar5 | 313 | | targz | tar.gz | tar -zxvf | bar6 | 314 | 315 | Scenario Outline: Ignores files matching pattern in all subdirectories of explicit directory: `blocks/src/block/**/*.js` 316 | # @see https://github.com/wp-cli/dist-archive-command/issues/44#issuecomment-1677135516 317 | Given an empty directory 318 | And a foo/.distignore file: 319 | """ 320 | blocks/src/block/**/*.ts 321 | """ 322 | And a foo/blocks/src/block/level1/test.ts file: 323 | """ 324 | excludeme 325 | """ 326 | And a foo/blocks/src/block/level1/test.js file: 327 | """ 328 | includeme 329 | """ 330 | And a foo/blocks/src/block/level1/level2/test.ts file: 331 | """ 332 | excludeme 333 | """ 334 | And a foo/blocks/src/block/level1/level2/test.js file: 335 | """ 336 | includeme 337 | """ 338 | 339 | When I run `wp dist-archive foo --format= --plugin-dirname=` 340 | Then STDOUT should match /^Success: Created . \(Size: \d+(?:\.\d*)? [a-zA-Z]{1,3}\)$/ 341 | And the . file should exist 342 | 343 | When I run `mv foo sourcefoo` 344 | Then the foo directory should not exist 345 | 346 | When I run `rm -rf ` 347 | Then the directory should not exist 348 | 349 | When I try ` .` 350 | Then the directory should exist 351 | And the /blocks/src/block/level1/test.ts file should not exist 352 | And the /blocks/src/block/level1/test.js file should exist 353 | And the /blocks/src/block/level1/level2/test.ts file should not exist 354 | And the /blocks/src/block/level1/level2/test.js file should exist 355 | 356 | Examples: 357 | | format | extension | extract | plugin-dirname | 358 | | zip | zip | unzip | foo | 359 | | targz | tar.gz | tar -zxvf | foo | 360 | | zip | zip | unzip | bar7 | 361 | | targz | tar.gz | tar -zxvf | bar8 | 362 | 363 | Scenario: Does not crash when a broken symlink is encountered 364 | # @see https://github.com/wp-cli/dist-archive-command/issues/86 365 | Given an empty directory 366 | And an empty foo/target-directory directory 367 | And a foo/.distignore file: 368 | """ 369 | """ 370 | 371 | When I run `ln -s {RUN_DIR}/foo/target-directory {RUN_DIR}/foo/symlink` 372 | Then STDERR should be empty 373 | 374 | When I run `rm -rf {RUN_DIR}/foo/target-directory` 375 | Then STDERR should be empty 376 | 377 | When I try `wp dist-archive foo` 378 | Then STDERR should contain: 379 | """ 380 | Error: Broken symlink at /symlink. Target missing at 381 | """ 382 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom ruleset for WP-CLI dist-archive-command 4 | 5 | 12 | 13 | 14 | . 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 38 | 39 | 41 | 42 | 43 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/Dist_Archive_Command.php: -------------------------------------------------------------------------------- 1 | 39 | * : Path to the project that includes a .distignore file. 40 | * 41 | * [] 42 | * : Path and optional file name for the distribution archive. 43 | * If only a path is provided, the file name defaults to the project directory name plus the version, if discoverable. 44 | * Also, if only a path is given, the directory that it points to has to already exist for the command to function correctly. 45 | * 46 | * [--create-target-dir] 47 | * : Automatically create the target directory as needed. 48 | * 49 | * [--force] 50 | * : Forces overwriting of the archive file if it already exists. 51 | * 52 | * [--plugin-dirname=] 53 | * : Set the archive extract directory name. Defaults to project directory name. 54 | * 55 | * [--format=] 56 | * : Choose the format for the archive. 57 | * --- 58 | * default: zip 59 | * options: 60 | * - zip 61 | * - targz 62 | * --- 63 | * 64 | * [--filename-format=] 65 | * : Use a custom format for archive filename. Available substitutions: {name}, {version}. 66 | * This is ignored if the parameter is provided or the version cannot be determined. 67 | * --- 68 | * default: "{name}.{version}" 69 | * --- 70 | * 71 | * @when before_wp_load 72 | */ 73 | public function __invoke( $args, $assoc_args ) { 74 | 75 | list( $source_dir_path, $destination_dir_path, $archive_file_name, $archive_output_dir_name ) = $this->get_file_paths_and_names( $args, $assoc_args ); 76 | 77 | $this->checker = new GitIgnoreChecker( $source_dir_path, '.distignore' ); 78 | $dist_ignore_filepath = $source_dir_path . '/.distignore'; 79 | if ( file_exists( $dist_ignore_filepath ) ) { 80 | $file_ignore_rules = explode( PHP_EOL, file_get_contents( $dist_ignore_filepath ) ); 81 | } else { 82 | WP_CLI::warning( 'No .distignore file found. All files in directory included in archive.' ); 83 | $file_ignore_rules = []; 84 | } 85 | 86 | if ( basename( $source_dir_path ) !== $archive_output_dir_name || $this->is_path_contains_symlink( $source_dir_path ) ) { 87 | $tmp_dir = sys_get_temp_dir() . '/' . uniqid( $archive_file_name ); 88 | $new_path = "{$tmp_dir}/{$archive_output_dir_name}"; 89 | mkdir( $new_path, 0777, true ); 90 | foreach ( $this->get_file_list( $source_dir_path ) as $relative_filepath ) { 91 | $source_item = $source_dir_path . $relative_filepath; 92 | if ( is_dir( $source_item ) ) { 93 | mkdir( "{$new_path}/{$relative_filepath}", 0777, true ); 94 | } else { 95 | copy( $source_item, $new_path . $relative_filepath ); 96 | } 97 | } 98 | $source_path = $new_path; 99 | } else { 100 | $source_path = $source_dir_path; 101 | } 102 | 103 | $archive_absolute_filepath = "{$destination_dir_path}/{$archive_file_name}"; 104 | 105 | if ( file_exists( $archive_absolute_filepath ) ) { 106 | $should_overwrite = Utils\get_flag_value( $assoc_args, 'force' ); 107 | if ( ! $should_overwrite ) { 108 | WP_CLI::warning( 'Archive file already exists' ); 109 | WP_CLI::log( $archive_absolute_filepath ); 110 | $answer = \cli\prompt( 111 | 'Do you want to skip or replace it with a new archive?', 112 | $default = false, 113 | $marker = ' [s/r]: ' 114 | ); 115 | $should_overwrite = 'r' === strtolower( $answer ); 116 | } 117 | if ( ! $should_overwrite ) { 118 | WP_CLI::log( 'Skipping' . PHP_EOL ); 119 | WP_CLI::log( 'Archive generation skipped.' ); 120 | exit( 0 ); 121 | } 122 | WP_CLI::log( "Replacing $archive_absolute_filepath" . PHP_EOL ); 123 | } 124 | 125 | chdir( dirname( $source_path ) ); 126 | 127 | // If the files are being zipped in place, we need the exclusion rules. 128 | // whereas if they were copied for any reasons above, the rules have already been applied. 129 | if ( $source_path !== $source_dir_path || empty( $file_ignore_rules ) ) { 130 | if ( 'zip' === $assoc_args['format'] ) { 131 | $cmd = "zip -r '{$archive_absolute_filepath}' {$archive_output_dir_name}"; 132 | } elseif ( 'targz' === $assoc_args['format'] ) { 133 | $cmd = "tar -zcvf {$archive_absolute_filepath} {$archive_output_dir_name}"; 134 | } 135 | } else { 136 | $tmp_dir = sys_get_temp_dir() . '/' . uniqid( $archive_file_name ); 137 | mkdir( $tmp_dir, 0777, true ); 138 | if ( 'zip' === $assoc_args['format'] ) { 139 | $include_list_filepath = $tmp_dir . '/include-file-list.txt'; 140 | file_put_contents( 141 | $include_list_filepath, 142 | trim( 143 | implode( 144 | "\n", 145 | array_map( 146 | function ( $relative_path ) use ( $source_path ) { 147 | return basename( $source_path ) . $relative_path; 148 | }, 149 | $this->get_file_list( $source_path ) 150 | ) 151 | ) 152 | ) 153 | ); 154 | $cmd = "zip --filesync -r '{$archive_absolute_filepath}' {$archive_output_dir_name} -i@{$include_list_filepath}"; 155 | } elseif ( 'targz' === $assoc_args['format'] ) { 156 | $exclude_list_filepath = "{$tmp_dir}/exclude-file-list.txt"; 157 | $excludes = array_filter( 158 | array_map( 159 | function ( $ignored_file ) use ( $source_path ) { 160 | $regex = preg_quote( basename( $source_path ) . $ignored_file, '\\' ); 161 | return ( php_uname( 's' ) === 'Linux' ) ? $regex : "^{$regex}$"; 162 | }, 163 | $this->get_file_list( $source_path, true ) 164 | ) 165 | ); 166 | file_put_contents( 167 | $exclude_list_filepath, 168 | trim( implode( "\n", $excludes ) ) 169 | ); 170 | $anchored_flag = ( php_uname( 's' ) === 'Linux' ) ? '--anchored ' : ''; 171 | $cmd = "tar {$anchored_flag} --exclude-from={$exclude_list_filepath} -zcvf {$archive_absolute_filepath} {$archive_output_dir_name}"; 172 | } 173 | } 174 | 175 | $escape_whitelist = 'targz' === $assoc_args['format'] ? array( '^', '*' ) : array(); 176 | WP_CLI::debug( "Running: {$cmd}", 'dist-archive' ); 177 | $escaped_shell_command = $this->escapeshellcmd( $cmd, $escape_whitelist ); 178 | $ret = WP_CLI::launch( $escaped_shell_command, false, true ); 179 | if ( 0 === $ret->return_code ) { 180 | $filename = pathinfo( $archive_absolute_filepath, PATHINFO_BASENAME ); 181 | $file_size = $this->get_size_format( filesize( $archive_absolute_filepath ), 2 ); 182 | 183 | WP_CLI::success( "Created {$filename} (Size: {$file_size})" ); 184 | } else { 185 | $error = $ret->stderr ?: $ret->stdout; 186 | WP_CLI::error( $error ); 187 | } 188 | } 189 | 190 | /** 191 | * Determine the full paths and names to use from the CLI input. 192 | * 193 | * I.e. the source directory, the output directory, the output filename, and the directory name the archive will 194 | * extract to. 195 | * 196 | * @param non-empty-array $args Source path (required), target (path or name, optional). 197 | * @param array{format:string,filename-format:string,plugin-dirname?:string,create-target-dir?:bool} $assoc_args 198 | * 199 | * @return string[] $source_dir_path, $destination_dir_path, $destination_archive_name, $archive_output_dir_name 200 | */ 201 | private function get_file_paths_and_names( $args, $assoc_args ) { 202 | 203 | $source_dir_path = realpath( $args[0] ); 204 | if ( ! is_dir( $source_dir_path ) ) { 205 | WP_CLI::error( 'Provided input path is not a directory.' ); 206 | } 207 | 208 | if ( isset( $args[1] ) ) { 209 | $destination_input = $args[1]; 210 | // If the end of the string is a filename (file.ext), use it for the output archive filename. 211 | if ( 1 === preg_match( '/(zip$|tar$|tar.gz$)/', $destination_input ) ) { 212 | $archive_file_name = basename( $destination_input ); 213 | 214 | // If only the filename was supplied, use the plugin's parent directory for output, otherwise use 215 | // the supplied directory. 216 | $destination_dir_path = basename( $destination_input ) === $destination_input 217 | ? dirname( $source_dir_path ) 218 | : dirname( $destination_input ); 219 | 220 | } else { 221 | // Only a path was supplied, not a filename. 222 | $destination_dir_path = $destination_input; 223 | $archive_file_name = null; 224 | } 225 | } else { 226 | // Use the plugin's parent directory for output. 227 | $destination_dir_path = dirname( $source_dir_path ); 228 | $archive_file_name = null; 229 | } 230 | 231 | // Convert relative path to absolute path (check does it begin with e.g. "c:" or "/"). 232 | if ( 1 !== preg_match( '/(^[a-zA-Z]+:|^\/)/', $destination_dir_path ) ) { 233 | $destination_dir_path = getcwd() . '/' . $destination_dir_path; 234 | } 235 | 236 | if ( Utils\get_flag_value( $assoc_args, 'create-target-dir' ) ) { 237 | $this->maybe_create_directory( $destination_dir_path ); 238 | } 239 | 240 | $destination_dir_path = realpath( $destination_dir_path ); 241 | 242 | if ( ! is_dir( $destination_dir_path ) ) { 243 | WP_CLI::error( "Target directory does not exist: {$destination_dir_path}" ); 244 | } 245 | 246 | // Use the optionally supplied plugin-dirname, or use the name of the directory containing the source files. 247 | $archive_output_dir_name = isset( $assoc_args['plugin-dirname'] ) 248 | ? rtrim( $assoc_args['plugin-dirname'], '/' ) 249 | : basename( $source_dir_path ); 250 | 251 | if ( is_null( $archive_file_name ) ) { 252 | $version = $this->get_version( $source_dir_path ); 253 | 254 | // If the version number has been found, substitute it into the filename-format template, or just use the name. 255 | $archive_file_stem = ! empty( $version ) 256 | ? str_replace( [ '{name}', '{version}' ], [ $archive_output_dir_name, $version ], $assoc_args['filename-format'] ) 257 | : $archive_output_dir_name; 258 | 259 | $archive_file_name = 'zip' === $assoc_args['format'] 260 | ? $archive_file_stem . '.zip' 261 | : $archive_file_stem . '.tar.gz'; 262 | } 263 | 264 | return [ $source_dir_path, $destination_dir_path, $archive_file_name, $archive_output_dir_name ]; 265 | } 266 | 267 | /** 268 | * Determine the plugin version from style.css, the main plugin .php file, or composer.json. 269 | * 270 | * Append the commit hash to `-alpha` versions. 271 | * 272 | * @param string $source_dir_path 273 | * 274 | * @return string 275 | */ 276 | private function get_version( $source_dir_path ) { 277 | 278 | $version = ''; 279 | 280 | /** 281 | * If the path is a theme (meaning it contains a style.css file) 282 | * parse the theme's version from the headers using a regex pattern. 283 | * The pattern used is extracted from the get_file_data() function in core. 284 | * 285 | * @link https://developer.wordpress.org/reference/functions/get_file_data/ 286 | */ 287 | if ( file_exists( $source_dir_path . '/style.css' ) ) { 288 | $contents = file_get_contents( $source_dir_path . '/style.css', false, null, 0, 5000 ); 289 | $contents = str_replace( "\r", "\n", $contents ); 290 | $pattern = '/^' . preg_quote( 'Version', ',' ) . ':(.*)$/mi'; 291 | if ( preg_match( $pattern, $contents, $match ) && $match[1] ) { 292 | $version = trim( preg_replace( '/\s*(?:\*\/|\?>).*/', '', $match[1] ) ); 293 | } 294 | } 295 | 296 | if ( empty( $version ) ) { 297 | foreach ( glob( $source_dir_path . '/*.php' ) as $php_file ) { 298 | $contents = file_get_contents( $php_file, false, null, 0, 5000 ); 299 | $version = $this->get_version_in_code( $contents ); 300 | if ( ! empty( $version ) ) { 301 | $version = trim( $version ); 302 | break; 303 | } 304 | } 305 | } 306 | 307 | if ( empty( $version ) && file_exists( $source_dir_path . '/composer.json' ) ) { 308 | $composer_obj = json_decode( file_get_contents( $source_dir_path . '/composer.json' ) ); 309 | if ( ! empty( $composer_obj->version ) ) { 310 | $version = trim( $composer_obj->version ); 311 | } 312 | } 313 | 314 | if ( ! empty( $version ) && false !== stripos( $version, '-alpha' ) && is_dir( $source_dir_path . '/.git' ) ) { 315 | $response = WP_CLI::launch( "cd {$source_dir_path}; git log --pretty=format:'%h' -n 1", false, true ); 316 | $maybe_hash = trim( $response->stdout ); 317 | if ( $maybe_hash && 7 === strlen( $maybe_hash ) ) { 318 | $version .= '-' . $maybe_hash; 319 | } 320 | } 321 | 322 | return $version; 323 | } 324 | 325 | /** 326 | * Create the directory for a target file if it does not exist yet. 327 | * 328 | * @param string $destination_dir_path Directory path for the target file. 329 | * @return void 330 | */ 331 | private function maybe_create_directory( $destination_dir_path ) { 332 | if ( ! is_dir( $destination_dir_path ) ) { 333 | mkdir( $destination_dir_path, $mode = 0777, $recursive = true ); 334 | } 335 | } 336 | 337 | /** 338 | * Gets the content of a version tag in any doc block in the given source code string. 339 | * 340 | * The version tag might be specified as "@version x.y.z" or "Version: x.y.z" and it can 341 | * be preceded by an asterisk (*). 342 | * 343 | * @param string $code_str The source code string to look into. 344 | * @return null|string The detected version string. 345 | */ 346 | private function get_version_in_code( $code_str ) { 347 | $tokens = array_values( 348 | array_filter( 349 | token_get_all( $code_str ), 350 | function ( $token ) { 351 | return ! is_array( $token ) || T_WHITESPACE !== $token[0]; 352 | } 353 | ) 354 | ); 355 | foreach ( $tokens as $token ) { 356 | if ( T_DOC_COMMENT === $token[0] ) { 357 | $version = $this->get_version_in_docblock( $token[1] ); 358 | if ( null !== $version ) { 359 | return $version; 360 | } 361 | } 362 | } 363 | return null; 364 | } 365 | 366 | /** 367 | * Gets the content of a version tag in a docblock. 368 | * 369 | * @param string $docblock Docblock to parse. 370 | * @return null|string The content of the version tag. 371 | */ 372 | private function get_version_in_docblock( $docblock ) { 373 | $docblocktags = $this->parse_doc_block( $docblock ); 374 | if ( isset( $docblocktags['version'] ) ) { 375 | return $docblocktags['version']; 376 | } 377 | return null; 378 | } 379 | 380 | /** 381 | * Parses a docblock and gets an array of tags with their values. 382 | * 383 | * The tags might be specified as "@version x.y.z" or "Version: x.y.z" and they can 384 | * be preceded by an asterisk (*). 385 | * 386 | * This code is based on the 'phpactor' package. 387 | * @see https://github.com/phpactor/docblock/blob/master/lib/Parser.php 388 | * 389 | * @param string $docblock Docblock to parse. 390 | * @return array Associative array of parsed data. 391 | */ 392 | private function parse_doc_block( $docblock ) { 393 | $tag_documentor = '{@([a-zA-Z0-9-_\\\]+)\s*?(.*)?}'; 394 | $tag_property = '{\s*\*?\s*(.*?):(.*)}'; 395 | $lines = explode( PHP_EOL, $docblock ); 396 | $tags = []; 397 | 398 | foreach ( $lines as $line ) { 399 | if ( 0 === preg_match( $tag_documentor, $line, $matches ) ) { 400 | if ( 0 === preg_match( $tag_property, $line, $matches ) ) { 401 | continue; 402 | } 403 | } 404 | 405 | $tag_name = strtolower( $matches[1] ); 406 | $metadata = trim( isset( $matches[2] ) ? $matches[2] : '' ); 407 | 408 | $tags[ $tag_name ] = $metadata; 409 | } 410 | return $tags; 411 | } 412 | 413 | /** 414 | * Run PHP's escapeshellcmd() then undo escaping known intentional characters. 415 | * 416 | * Escaped by default: &#;`|*?~<>^()[]{}$\, \x0A and \xFF. ' and " are escaped when not paired. 417 | * 418 | * @see escapeshellcmd() 419 | * 420 | * @param string $cmd The shell command to escape. 421 | * @param string[] $whitelist Array of exceptions to allow in the escaped command. 422 | * 423 | * @return string 424 | */ 425 | protected function escapeshellcmd( $cmd, $whitelist ) { 426 | 427 | $escaped_command = escapeshellcmd( $cmd ); 428 | 429 | foreach ( $whitelist as $undo_escape ) { 430 | $escaped_command = str_replace( '\\' . $undo_escape, $undo_escape, $escaped_command ); 431 | } 432 | 433 | return $escaped_command; 434 | } 435 | 436 | 437 | /** 438 | * Given the path to a directory, check are any of the directories inside it symlinks. 439 | * 440 | * If the plugin contains a symlink, we will first copy it to a temp directory, potentially omitting any 441 | * symlinks that are excluded via the `.distignore` file, avoiding recursive loops as described in #57. 442 | * 443 | * @param string $source_dir_path The path to the directory to check. 444 | * 445 | * @return bool 446 | */ 447 | protected function is_path_contains_symlink( $source_dir_path ) { 448 | 449 | if ( ! is_dir( $source_dir_path ) ) { 450 | throw new Exception( 'Path `' . $source_dir_path . '` is not a directory' ); 451 | } 452 | 453 | $iterator = new RecursiveIteratorIterator( 454 | new RecursiveDirectoryIterator( $source_dir_path, RecursiveDirectoryIterator::SKIP_DOTS ), 455 | RecursiveIteratorIterator::SELF_FIRST 456 | ); 457 | 458 | /** 459 | * @var RecursiveIteratorIterator $iterator 460 | * @var SplFileInfo $item 461 | */ 462 | foreach ( $iterator as $item ) { 463 | if ( is_link( $item->getPathname() ) ) { 464 | return true; 465 | } 466 | } 467 | return false; 468 | } 469 | 470 | /** 471 | * Filter all files in a path to either: a list of files to include in, or a list of files to exclude from, the archive. 472 | * 473 | * Exclude list should contain directory names when no files in that directory exist in the include list. 474 | * 475 | * @param string $source_dir_path Path to process. 476 | * @param bool $excluded Whether to return the list of files to exclude. Default (false) returns the list of files to include. 477 | * @return string[] Filtered list of files to include or exclude (depending on $excluded flag). 478 | */ 479 | private function get_file_list( $source_dir_path, $excluded = false ) { 480 | 481 | $included_files = []; 482 | $excluded_files = []; 483 | 484 | $iterator = new RecursiveIteratorIterator( 485 | new RecursiveDirectoryIterator( $source_dir_path, RecursiveDirectoryIterator::SKIP_DOTS ), 486 | RecursiveIteratorIterator::SELF_FIRST 487 | ); 488 | 489 | /** 490 | * @var RecursiveIteratorIterator $iterator 491 | * @var SplFileInfo $item 492 | */ 493 | foreach ( $iterator as $item ) { 494 | $relative_filepath = str_replace( $source_dir_path, '', $item->getPathname() ); 495 | try { 496 | if ( $this->checker->isPathIgnored( $relative_filepath ) ) { 497 | $excluded_files[] = $relative_filepath; 498 | } else { 499 | $included_files[] = $relative_filepath; 500 | } 501 | } catch ( \Inmarelibero\GitIgnoreChecker\Exception\InvalidArgumentException $exception ) { 502 | if ( $item->isLink() && ! file_exists( readlink( $item->getPathname() ) ) ) { 503 | WP_CLI::error( "Broken symlink at {$relative_filepath}. Target missing at {$item->getLinkTarget()}." ); 504 | } else { 505 | WP_CLI::error( $exception->getMessage() ); 506 | } 507 | } 508 | } 509 | 510 | // Check all excluded directories and remove them from the excluded list if they contain included files. 511 | foreach ( $excluded_files as $excluded_file_index => $excluded_relative_path ) { 512 | if ( ! is_dir( $source_dir_path . $excluded_relative_path ) ) { 513 | continue; 514 | } 515 | foreach ( $included_files as $included_relative_path ) { 516 | if ( 0 === strpos( $included_relative_path, $excluded_relative_path ) ) { 517 | unset( $excluded_files[ $excluded_file_index ] ); 518 | } 519 | } 520 | } 521 | 522 | return $excluded ? $excluded_files : $included_files; 523 | } 524 | 525 | /** 526 | * Converts a number of bytes to the largest unit the bytes will fit into. 527 | * 528 | * @param int $bytes Number of bytes. 529 | * @param int $decimals Precision of number of decimal places. 530 | * @return string Number string. 531 | */ 532 | private function get_size_format( $bytes, $decimals = 0 ) { 533 | // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Backfilling WP native constants. 534 | if ( ! defined( 'KB_IN_BYTES' ) ) { 535 | define( 'KB_IN_BYTES', 1024 ); 536 | } 537 | if ( ! defined( 'MB_IN_BYTES' ) ) { 538 | define( 'MB_IN_BYTES', 1024 * KB_IN_BYTES ); 539 | } 540 | if ( ! defined( 'GB_IN_BYTES' ) ) { 541 | define( 'GB_IN_BYTES', 1024 * MB_IN_BYTES ); 542 | } 543 | if ( ! defined( 'TB_IN_BYTES' ) ) { 544 | define( 'TB_IN_BYTES', 1024 * GB_IN_BYTES ); 545 | } 546 | // phpcs:enable 547 | 548 | $size_key = floor( log( $bytes ) / log( 1000 ) ); 549 | $sizes = [ 'B', 'KB', 'MB', 'GB', 'TB' ]; 550 | 551 | $size_format = isset( $sizes[ $size_key ] ) ? $sizes[ $size_key ] : $sizes[0]; 552 | 553 | // Display the size as a number. 554 | switch ( $size_format ) { 555 | case 'TB': 556 | $divisor = pow( 1000, 4 ); 557 | break; 558 | 559 | case 'GB': 560 | $divisor = pow( 1000, 3 ); 561 | break; 562 | 563 | case 'MB': 564 | $divisor = pow( 1000, 2 ); 565 | break; 566 | 567 | case 'KB': 568 | $divisor = 1000; 569 | break; 570 | 571 | case 'tb': 572 | case 'TiB': 573 | $divisor = TB_IN_BYTES; 574 | break; 575 | 576 | case 'gb': 577 | case 'GiB': 578 | $divisor = GB_IN_BYTES; 579 | break; 580 | 581 | case 'mb': 582 | case 'MiB': 583 | $divisor = MB_IN_BYTES; 584 | break; 585 | 586 | case 'kb': 587 | case 'KiB': 588 | $divisor = KB_IN_BYTES; 589 | break; 590 | 591 | case 'b': 592 | case 'B': 593 | default: 594 | $divisor = 1; 595 | break; 596 | } 597 | 598 | $size_format_display = preg_replace( '/IB$/u', 'iB', strtoupper( $size_format ) ); 599 | 600 | return round( (int) $bytes / $divisor, $decimals ) . ' ' . $size_format_display; 601 | } 602 | } 603 | -------------------------------------------------------------------------------- /wp-cli.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - dist-archive-command.php 3 | --------------------------------------------------------------------------------