├── .github ├── actions │ ├── setup │ │ └── action.yml │ └── test │ │ └── action.yml └── workflows │ ├── check.yml │ └── release.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── godot.lock ├── godot.package ├── src ├── archive.rs ├── cache.rs ├── config_file.rs ├── conversions.rs ├── main.rs ├── package.rs ├── package │ └── parsing.rs ├── theme.rs └── verbosity.rs └── test-server ├── Cargo.toml └── src ├── data.rs ├── lib.rs └── main.rs /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup 2 | description: setup rust toolchains, checkout, and restore cache 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: get toolchains 8 | run: | 9 | rustup set auto-self-update disable 10 | rustup toolchain install stable --profile minimal 11 | rustup target add x86_64-pc-windows-gnu x86_64-apple-darwin x86_64-unknown-linux-gnu 12 | sudo apt-get install -y gcc-mingw-w64 13 | shell: bash 14 | 15 | - name: setup osxcross 16 | uses: mbround18/setup-osxcross@main 17 | with: 18 | osx-version: "12.3" 19 | 20 | - name: checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: cache rust 24 | uses: Swatinem/rust-cache@v2 25 | with: 26 | shared-key: cache01 27 | -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | description: Runs tests 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Test 8 | run: cargo test -r --all-targets 9 | shell: bash 10 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ main ] 7 | paths: 8 | - "src/**.rs" 9 | - "!Cargo.toml" 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: setup 17 | uses: godot-package-manager/cli/.github/actions/setup@main 18 | 19 | - name: test 20 | uses: godot-package-manager/cli/.github/actions/test@main 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "release" 2 | on: 3 | push: 4 | branches: [main] 5 | paths: 6 | - Cargo.toml 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | permissions: 14 | contents: write 15 | 16 | jobs: 17 | release: 18 | name: release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: setup 22 | uses: godot-package-manager/cli/.github/actions/setup@main 23 | 24 | - name: test 25 | uses: godot-package-manager/cli/.github/actions/test@main 26 | 27 | - name: build 28 | id: build 29 | run: | 30 | version="$(grep "version = " Cargo.toml | head -n 1 | awk -F '"' '{ print $2 }')" 31 | [[ $(git tag | grep -c "$version") != 0 ]] && exit 0 32 | echo "release=$version" >> $GITHUB_OUTPUT 33 | echo "## building" > $GITHUB_STEP_SUMMARY 34 | cargo build --target x86_64-pc-windows-gnu --target x86_64-unknown-linux-gnu -r 35 | export CC=o64-clang; export CXX=o64-clang++; cargo build --target x86_64-apple-darwin -r 36 | mv target/x86_64-apple-darwin/release/godot-package-manager target/x86_64-apple-darwin/release/godot-package-manager.apple.x86_64 # no conflicts 37 | mv target/x86_64-unknown-linux-gnu/release/godot-package-manager target/x86_64-unknown-linux-gnu/release/godot-package-manager.x86_64 38 | echo -e "## releasing\n$(ls target/*/release)" > $GITHUB_STEP_SUMMARY 39 | 40 | - name: release artifacts 41 | if: steps.build.outputs.release 42 | uses: softprops/action-gh-release@v1 43 | with: 44 | files: | 45 | target/x86_64-unknown-linux-gnu/release/godot-package-manager.x86_64 46 | target/x86_64-apple-darwin/release/godot-package-manager.apple.x86_64 47 | target/x86_64-pc-windows-gnu/release/godot-package-manager.exe 48 | tag_name: ${{ steps.build.outputs.release }} 49 | name: gpm v${{ steps.build.outputs.release }} 50 | body: "## :tada:" 51 | fail_on_unmatched_files: true 52 | generate_release_notes: true 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # Addons folder 17 | addons/ 18 | 19 | # Performance data 20 | perf.data* 21 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "godot-package-manager" 3 | version = "1.4.0" 4 | edition = "2021" 5 | authors = ["bendn "] 6 | description = "A package manager for godot" 7 | repository = "https://github.com/godot-package-manager/cli" 8 | license = "Apache-2.0" 9 | 10 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 11 | 12 | [dependencies] 13 | clap = { version = "4.0.29", features = ["derive"] } 14 | deser-hjson = "1.0.2" 15 | lazy_static = "1.4.0" 16 | regex = "1.7.0" 17 | serde = { version = "1.0.150", features = ["derive"] } 18 | serde_json = "1.0.89" 19 | serde_yaml = "0.9.14" 20 | tar = "0.4.38" 21 | flate2 = "1.0.25" 22 | zip = { version = "0.6", features = ["bzip2"] } 23 | toml = "0.5.10" 24 | sha1 = "0.10.5" 25 | console = "0.15.4" 26 | indicatif = "0.17.2" 27 | anyhow = "1.0.68" 28 | dialoguer = { version = "0.10.3", default-features = false, features = [] } 29 | reqwest = "0.11" 30 | tokio = { version = "1", features = ["macros", "net"] } 31 | async-recursion = "1.0.2" 32 | futures = "0.3" 33 | semver_rs = "0.2" 34 | async-trait = "0.1.66" 35 | dashmap = "5.4.0" 36 | 37 | [dev-dependencies] 38 | test-server = { path = "test-server" } 39 | glob = "0.3.0" 40 | sha2 = "0.10.6" 41 | tempfile = "3.5.0" 42 | fastrand = "1.9.0" 43 | 44 | [profile.dev] 45 | debug = true 46 | 47 | [profile.release] 48 | lto = true 49 | strip = true 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | License shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | Licensor shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | Legal Entity shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | control means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | You (or Your) shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | Source form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | Object form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | Work shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | Derivative Works shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | Contribution shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, submitted 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as Not a Contribution. 61 | 62 | Contributor shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a NOTICE text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an AS IS BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets [] 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same printed page as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022-present bendn 190 | 191 | Licensed under the Apache License, Version 2.0 (the License); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an AS IS BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Godot Package Manager rust client 2 | 3 | [![discord](https://img.shields.io/discord/853476898071117865?label=chat&logo=discord&style=for-the-badge&logoColor=white)](https://discord.gg/6mcdWWBkrr "Chat on Discord") 4 | [![aur](https://img.shields.io/aur/version/godot-package-manager-git?color=informative&logo=archlinux&logoColor=white&style=for-the-badge)](https://aur.archlinux.org/packages/godot-package-manager-git "AUR package") 5 | 6 | ## Installation 7 | 8 | > **Note** read the [using packages quickstart](https://github.com/godot-package-manager#using-packages-quickstart) first. 9 | 10 |
11 | Manual 12 | 13 | 1. Download the [latest version](https://github.com/godot-package-manager/cli/releases/latest) 14 | 2. Move the executable to your `PATH` as `gpm` 15 | 16 |
17 |
18 | ArchLinux 19 | 20 | There's an AUR package available: [godot-package-manager-git](https://aur.archlinux.org/packages/godot-package-manager-git) 21 | 22 | > **Note** This package installs to /usr/bin/godot-package-manager to avoid conflicts with [general purpose mouse](https://www.nico.schottelius.org/software/gpm/). Assuming you have `yay` installed: 23 | 24 | 1. `yay -S godot-package-manager-git` 25 | 26 |
27 | 28 | ## Usage 29 | 30 | ```bash 31 | gpm update # downloads the newest versions of packages 32 | gpm purge # removes the installed packages 33 | gpm tree # prints the tree of installed packages, looks like 34 | # /home/my-package 35 | # └── @bendn/test@2.0.10 36 | # └── @bendn/gdcli@1.2.5 37 | ``` 38 | 39 | ## Compiling 40 | 41 | 1. `git clone --depth 5 https://github.com/godot-package-manager/client`) 42 | 2. `cargo build -r` 43 | 3. Executable is `target/release/godot-package-manager` 44 | -------------------------------------------------------------------------------- /godot.lock: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "@bendn/gdcli", 4 | "tarball": "https://registry.npmjs.org/@bendn/gdcli/-/gdcli-1.2.5.tgz", 5 | "version": "1.2.5" 6 | }, 7 | { 8 | "name": "@bendn/splitter", 9 | "tarball": "https://github.com/bend-n/splitter/archive/refs/heads/main.zip", 10 | "version": "1.1.0" 11 | }, 12 | { 13 | "name": "@bendn/stockfish.gd", 14 | "tarball": "https://registry.npmjs.org/@bendn/stockfish.gd/-/stockfish.gd-1.2.6.tgz", 15 | "version": "1.2.6" 16 | }, 17 | { 18 | "name": "@bendn/test", 19 | "tarball": "https://registry.npmjs.org/@bendn/test/-/test-2.0.10.tgz", 20 | "version": "2.0.10" 21 | } 22 | ] -------------------------------------------------------------------------------- /godot.package: -------------------------------------------------------------------------------- 1 | packages: { 2 | @bendn/test: "^2.0.0" 3 | @bendn/stockfish.gd: "1.*" 4 | "https://github.com/bend-n/splitter/archive/refs/heads/main.zip": "1.0.x" 5 | } 6 | -------------------------------------------------------------------------------- /src/archive.rs: -------------------------------------------------------------------------------- 1 | use crate::config_file::*; 2 | use crate::ctx; 3 | use crate::package::Package; 4 | use crate::Client; 5 | use anyhow::{Context, Result}; 6 | use flate2::bufread::GzDecoder; 7 | use serde::Serialize; 8 | use std::fmt::Display; 9 | use std::fs::{create_dir_all, set_permissions, File, Permissions}; 10 | use std::io::{self, prelude::*, Cursor}; 11 | use std::path::{Component::Normal, Path, PathBuf}; 12 | use tar::Archive as Tarchive; 13 | use tar::EntryType::Directory; 14 | use zip::result::{ZipError, ZipResult}; 15 | use zip::ZipArchive as Zarchive; 16 | 17 | type TArch = Tarchive>>>; 18 | type ZArch = Zarchive>>; 19 | 20 | #[derive(Default, Clone, Serialize, PartialEq, Eq, Ord, PartialOrd, Hash, Debug)] 21 | pub struct Data { 22 | #[serde(skip)] 23 | pub bytes: Vec, 24 | pub uri: String, 25 | } 26 | 27 | impl Data { 28 | pub fn new(bytes: Vec, uri: String) -> Self { 29 | Self { bytes, uri } 30 | } 31 | pub fn new_bytes(bytes: Vec) -> Self { 32 | Self { 33 | bytes, 34 | uri: String::new(), 35 | } 36 | } 37 | pub fn new_uri(uri: String) -> Self { 38 | Self { bytes: vec![], uri } 39 | } 40 | } 41 | 42 | #[derive(Default, Clone, Serialize, PartialEq, Eq, Ord, PartialOrd, Hash, Debug)] 43 | #[serde(untagged)] 44 | pub enum CompressionType { 45 | Gzip(Data), 46 | Zip(Data), 47 | Lock(String), 48 | #[default] 49 | None, 50 | } 51 | 52 | impl Display for CompressionType { 53 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 54 | match self { 55 | CompressionType::Gzip(d) => write!(f, "{}", d.uri), 56 | CompressionType::Zip(d) => write!(f, "{}", d.uri), 57 | CompressionType::Lock(d) => write!(f, "{}", d), 58 | _ => unreachable!(), 59 | } 60 | } 61 | } 62 | 63 | impl CompressionType { 64 | pub fn from(ty: &str, bytes: Vec, uri: String) -> Self { 65 | match ty { 66 | "zip" => Self::Zip(Data::new(bytes, uri)), 67 | _ => Self::Gzip(Data::new(bytes, uri)), 68 | } 69 | } 70 | 71 | pub fn lock(&mut self) { 72 | *self = Self::Lock(match self { 73 | CompressionType::Gzip(d) => std::mem::take(&mut d.uri), 74 | CompressionType::Zip(d) => std::mem::take(&mut d.uri), 75 | _ => unreachable!(), 76 | }) 77 | } 78 | } 79 | 80 | enum ArchiveType { 81 | Gzip(Box), 82 | Zip(ZArch), 83 | } 84 | 85 | pub struct Archive { 86 | inner: ArchiveType, 87 | uri: String, 88 | } 89 | 90 | // impl<'a, Z> From> for Archive<'a> { 91 | // fn from(value: TArch<'a>) -> Self { 92 | // Self::Gzip(value) 93 | // } 94 | // } 95 | 96 | // impl<'a> From> for Archive<'a> { 97 | // fn from(value: ZArch<'a>) -> Self { 98 | // Self::Zip(value) 99 | // } 100 | // } 101 | 102 | fn unpack_zarchive(archive: &mut ZArch, dst: &Path) -> ZipResult<()> { 103 | if dst.symlink_metadata().is_err() { 104 | create_dir_all(dst).map_err(ZipError::Io)?; 105 | } 106 | let dst = &dst.canonicalize().unwrap_or(dst.to_path_buf()); 107 | 108 | let mut directories = vec![]; 109 | for i in 0..archive.len() { 110 | let mut file = archive.by_index(i)?; 111 | let path = dst.join(skip_toplevel( 112 | file.enclosed_name().ok_or(ZipError::FileNotFound)?, 113 | )); 114 | if file.is_dir() { 115 | directories.push(path); 116 | } else { 117 | create_dir_all(path.parent().unwrap())?; 118 | let mut outfile = File::create(&path)?; 119 | io::copy(&mut file, &mut outfile)?; 120 | #[cfg(unix)] 121 | { 122 | use std::os::unix::fs::PermissionsExt; 123 | if let Some(mode) = file.unix_mode() { 124 | set_permissions(&path, Permissions::from_mode(mode))?; 125 | } 126 | } 127 | } 128 | } 129 | for path in directories { 130 | create_dir_all(path)?; 131 | } 132 | Ok(()) 133 | } 134 | 135 | fn skip_toplevel(p: &Path) -> PathBuf { 136 | p.components() 137 | .skip(1) 138 | .filter(|c| matches!(c, Normal(_))) 139 | .collect::() 140 | } 141 | 142 | fn unpack_tarchive(archive: &mut TArch, dst: &Path) -> io::Result<()> { 143 | if dst.symlink_metadata().is_err() { 144 | create_dir_all(dst)?; 145 | } 146 | 147 | let dst = &dst.canonicalize().unwrap_or(dst.to_path_buf()); 148 | 149 | // Delay any directory entries until the end (they will be created if needed by 150 | // descendants), to ensure that directory permissions do not interfer with descendant 151 | // extraction. 152 | let mut directories = Vec::new(); 153 | for entry in archive.entries()? { 154 | let entry = entry?; 155 | let mut entry = (dst.join(skip_toplevel(&entry.path()?)), entry); 156 | if entry.1.header().entry_type() == Directory { 157 | directories.push(entry); 158 | } else { 159 | create_dir_all(entry.0.parent().unwrap())?; 160 | entry.1.unpack(entry.0)?; 161 | } 162 | } 163 | for mut dir in directories { 164 | dir.1.unpack(dir.0)?; 165 | } 166 | Ok(()) 167 | } 168 | 169 | fn get_zfile(zarchive: &mut ZArch, search: &str, out: &mut String) -> ZipResult<()> { 170 | for i in 0..zarchive.len() { 171 | let mut file = zarchive.by_index(i)?; 172 | if let Some(n) = file.enclosed_name() { 173 | if let Some(base) = &Path::new(n).file_name() { 174 | if base.to_string_lossy() == search { 175 | file.read_to_string(out)?; 176 | return Ok(()); 177 | } 178 | } 179 | } 180 | } 181 | Err(ZipError::FileNotFound) 182 | } 183 | 184 | fn get_gfile(tarchive: &mut TArch, file: &str, out: &mut String) -> io::Result<()> { 185 | for entry in tarchive.entries()? { 186 | let mut entry = entry?; 187 | if let Ok(p) = entry.path() { 188 | if p.file_name().ok_or(io::ErrorKind::InvalidData)? == file { 189 | entry.read_to_string(out)?; 190 | return Ok(()); 191 | } 192 | } 193 | } 194 | Err(io::ErrorKind::InvalidData.into()) 195 | } 196 | 197 | impl Archive { 198 | pub fn unpack(&mut self, dst: &Path) -> Result<()> { 199 | match &mut self.inner { 200 | ArchiveType::Gzip(g) => unpack_tarchive(g, dst)?, 201 | ArchiveType::Zip(z) => unpack_zarchive(z, dst)?, 202 | } 203 | Ok(()) 204 | } 205 | 206 | pub fn get_file(&mut self, file: &str, out: &mut String) -> Result<()> { 207 | match &mut self.inner { 208 | ArchiveType::Gzip(g) => get_gfile(g, file, out)?, 209 | ArchiveType::Zip(z) => get_zfile(z, file, out)?, 210 | } 211 | Ok(()) 212 | } 213 | 214 | fn wrap(wrap: ArchiveType, uri: String) -> Self { 215 | Self { inner: wrap, uri } 216 | } 217 | 218 | pub fn new(value: CompressionType) -> Result { 219 | match value { 220 | CompressionType::Gzip(data) => Ok(Self::new_gzip(data.bytes, data.uri)), 221 | CompressionType::Zip(data) => Self::new_zip(data.bytes, data.uri), 222 | _ => unreachable!(), 223 | } 224 | } 225 | 226 | pub fn new_gzip(value: Vec, uri: String) -> Self { 227 | Self::wrap( 228 | ArchiveType::Gzip(Box::new(Tarchive::new(GzDecoder::new(Cursor::new(value))))), 229 | uri, 230 | ) 231 | } 232 | 233 | pub fn new_zip(value: Vec, uri: String) -> Result { 234 | Ok(Self::wrap( 235 | ArchiveType::Zip(Zarchive::new(Cursor::new(value))?), 236 | uri, 237 | )) 238 | } 239 | /// async trait + lifetimes = boom 240 | pub async fn into_package(mut self, client: Client) -> Result { 241 | let mut contents = String::new(); 242 | { 243 | ctx!( 244 | self.get_file("package.json", &mut contents), 245 | "searching for package.json" 246 | )?; 247 | } 248 | let ty = match self.inner { 249 | ArchiveType::Zip(_) => CompressionType::Zip(Data::new_uri(self.uri)), 250 | ArchiveType::Gzip(_) => CompressionType::Gzip(Data::new_uri(self.uri)), 251 | }; 252 | ctx!( 253 | ctx!( 254 | ConfigFile::parse(&contents, ConfigType::JSON, client).await, 255 | "parsing config file from package.json inside zipfile" 256 | )? 257 | .into_package(ty), 258 | "turning config file into package" 259 | ) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | use crate::archive::*; 2 | use crate::conversions::TryIntoAsync; 3 | use crate::package::parsing::{Packument, ParsedManifest, ParsedPackage}; 4 | use crate::package::Package; 5 | use crate::{ctx, Client}; 6 | 7 | use anyhow::{Context, Result}; 8 | use dashmap::mapref::entry::Entry; 9 | use dashmap::mapref::multiple::{RefMulti, RefMutMulti}; 10 | use dashmap::mapref::one::{Ref, RefMut}; 11 | use dashmap::DashMap; 12 | use semver_rs::{Range, Version}; 13 | use std::sync::Arc; 14 | 15 | type O<'a, T> = Option>; 16 | pub type R<'a> = RefMutMulti<'a, String, CacheEntry>; 17 | 18 | #[derive(Clone)] 19 | pub struct Cache { 20 | inner: Arc>, 21 | } 22 | 23 | #[derive(Default, Clone)] 24 | pub struct VersionsCache { 25 | inner: DashMap, 26 | } 27 | 28 | impl Cache { 29 | pub fn new() -> Self { 30 | Self { 31 | inner: Arc::new(DashMap::default()), 32 | } 33 | } 34 | 35 | /// Deadlocks when mutable reference held 36 | pub fn get(&self, name: &str) -> O { 37 | self.inner.get(name) 38 | } 39 | /// Deadlocks when reference held 40 | pub fn get_mut(&self, name: &str) -> Option> { 41 | self.inner.get_mut(name) 42 | } 43 | /// Deadlocks when reference held 44 | pub fn insert(&self, name: String, version: String, entry: CacheEntry) { 45 | self.inner.entry(name).or_default().insert(version, entry); 46 | } 47 | /// Deadlocks when reference held 48 | pub fn entry(&self, name: String) -> Entry<'_, String, VersionsCache> { 49 | self.inner.entry(name) 50 | } 51 | } 52 | 53 | impl VersionsCache { 54 | pub fn insert_packument(&mut self, pack: Packument) -> &mut Self { 55 | for manif in pack.versions { 56 | self.insert(manif.version.clone(), manif.into()) 57 | } 58 | self 59 | } 60 | pub fn iter_versions( 61 | &mut self, 62 | ) -> impl Iterator)> { 63 | self.iter_mut() 64 | .map(|x| (Self::version_of(x.key(), x.value().clone()), x)) 65 | } 66 | 67 | pub fn get(&self, v: &str) -> Option> { 68 | self.inner.get(v) 69 | } 70 | 71 | pub fn versions(&self) -> impl Iterator + '_ { 72 | self.iter() 73 | .map(|x| Self::version_of(x.key(), x.value().clone())) 74 | } 75 | 76 | fn version_of(k: &str, entry: CacheEntry) -> Version { 77 | if let CacheEntry::Parsed(p) = entry { 78 | p.manifest.version 79 | } else { 80 | Version::new(k).parse().unwrap() 81 | } 82 | } 83 | 84 | pub fn iter(&self) -> impl Iterator> { 85 | self.inner.iter() 86 | } 87 | 88 | pub fn iter_mut(&mut self) -> impl Iterator { 89 | self.inner.iter_mut() 90 | } 91 | 92 | pub fn insert(&mut self, k: String, v: CacheEntry) { 93 | self.inner.insert(k, v); 94 | } 95 | 96 | #[must_use] 97 | /// if found and unparsed, swaps unparsed for parsed 98 | pub fn find_version(&mut self, v: &Range) -> Option { 99 | let mut newest = None; 100 | for (version, entry) in self.iter_versions() { 101 | if v.test(&version) { 102 | // if v.exact() { return immediately } 103 | if let Some((_, v)) = &newest { 104 | if version.cmp(v) == std::cmp::Ordering::Less { 105 | continue; 106 | } 107 | } 108 | newest = Some((entry, version)) 109 | } 110 | } 111 | // todo: reuse this parsed version 112 | if let Some((e, _)) = newest { 113 | return Some(e); 114 | } 115 | None 116 | } 117 | } 118 | 119 | impl std::fmt::Debug for VersionsCache { 120 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 121 | let mut iter = self.versions(); 122 | if let Some(first) = iter.next() { 123 | write!(f, "{first}")?; 124 | for elem in iter { 125 | write!(f, ", {elem}")?; 126 | } 127 | } 128 | Ok(()) 129 | } 130 | } 131 | 132 | #[derive(Default, Clone)] // yuck, a clone 133 | pub enum CacheEntry { 134 | Unparsed(ParsedPackage), 135 | Parsed(Package), 136 | Manifest(ParsedManifest), 137 | Tarball(CompressionType), 138 | #[default] 139 | Empty, 140 | } 141 | 142 | impl From for CacheEntry { 143 | fn from(value: CompressionType) -> Self { 144 | Self::Tarball(value) 145 | } 146 | } 147 | 148 | impl From for CacheEntry { 149 | fn from(value: Package) -> Self { 150 | Self::Parsed(value) 151 | } 152 | } 153 | impl From for CacheEntry { 154 | fn from(value: ParsedManifest) -> Self { 155 | Self::Manifest(value) 156 | } 157 | } 158 | impl From for CacheEntry { 159 | fn from(value: ParsedPackage) -> Self { 160 | Self::Unparsed(value) 161 | } 162 | } 163 | 164 | impl CacheEntry { 165 | pub async fn parse(&mut self, client: Client, name: String) -> Result<()> { 166 | *self = CacheEntry::from(match self { 167 | CacheEntry::Unparsed(p) => std::mem::take(p).into_package(client).await?, 168 | CacheEntry::Manifest(m) => { 169 | let m = ctx!( 170 | std::mem::take(m).try_into_async(client).await, 171 | "parsing ParsedManifest into Manifest in get_package()" 172 | )?; 173 | Package::from_manifest(m, name.clone()) 174 | } 175 | CacheEntry::Tarball(t) => { 176 | Archive::new(std::mem::take(t))? 177 | .into_package(client) 178 | .await? 179 | } 180 | _ => return Ok(()), 181 | }); 182 | Ok(()) 183 | } 184 | 185 | pub fn get_package(&self) -> Package { 186 | match self { 187 | CacheEntry::Parsed(p) => p.clone(), 188 | _ => unreachable!(), 189 | } 190 | } 191 | 192 | // pub fn get_bytes(&self) -> &Vec { 193 | // match self { 194 | // CacheEntry::Tarball(t) => t, 195 | // _ => unreachable!(), 196 | // } 197 | // } 198 | } 199 | -------------------------------------------------------------------------------- /src/config_file.rs: -------------------------------------------------------------------------------- 1 | use crate::conversions::*; 2 | use crate::ctx; 3 | use crate::package::Manifest; 4 | use crate::package::Package; 5 | use crate::Client; 6 | 7 | use anyhow::{Context, Result}; 8 | use console::style; 9 | use semver_rs::Version; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::{HashMap, HashSet}; 12 | use std::path::Path; 13 | 14 | /// The config file: parsed from godot.package, usually. 15 | #[derive(Default)] 16 | pub struct ConfigFile { 17 | name: String, 18 | version: String, 19 | pub packages: Vec, 20 | // hooks: there are no hooks now 21 | } 22 | 23 | #[derive(Deserialize, Serialize, Default)] 24 | #[serde(default)] 25 | /// A wrapper to [ConfigFile]. This _is_ necessary. 26 | /// Any alternatives will end up being more ugly than this. (trust me i tried) 27 | /// There is no way to automatically deserialize the map into a vec. 28 | struct ParsedConfig { 29 | // support NPM package.json files (also allows gpm -c package.json -u) 30 | #[serde(alias = "dependencies")] 31 | packages: HashMap, 32 | #[serde(default)] 33 | name: String, 34 | #[serde(default)] 35 | version: String, 36 | } 37 | 38 | #[derive(Debug, Clone, Copy)] 39 | pub enum ConfigType { 40 | JSON, 41 | YAML, 42 | TOML, 43 | } 44 | 45 | impl std::fmt::Display for ConfigType { 46 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 47 | write!(f, "{:#?}", self) 48 | } 49 | } 50 | 51 | impl From<&ConfigFile> for ParsedConfig { 52 | fn from(from: &ConfigFile) -> Self { 53 | Self { 54 | packages: from 55 | .packages 56 | .iter() 57 | .map(|p| (p.name.to_string(), p.manifest.version.to_string())) 58 | .collect(), 59 | name: String::new(), 60 | version: String::new(), 61 | } 62 | } 63 | } 64 | 65 | #[async_trait::async_trait] 66 | impl TryFromAsync for ConfigFile { 67 | async fn try_from_async(value: ParsedConfig, client: Client) -> Result { 68 | let mut packages: Vec = ctx!( 69 | value.packages.try_into_async(client).await, 70 | "turning ParsedConfig into ConfigFile" 71 | ) 72 | .unwrap(); 73 | for mut p in &mut packages { 74 | p.indirect = false 75 | } 76 | Ok(ConfigFile { 77 | packages, 78 | name: value.name, 79 | version: value.version, 80 | }) 81 | } 82 | } 83 | 84 | impl ParsedConfig { 85 | pub fn parse(txt: &str, t: ConfigType) -> Result { 86 | Ok(match t { 87 | ConfigType::TOML => toml::from_str::(txt)?, 88 | ConfigType::JSON => deser_hjson::from_str::(txt)?, 89 | ConfigType::YAML => serde_yaml::from_str::(txt)?, 90 | }) 91 | } 92 | } 93 | 94 | impl ConfigFile { 95 | pub fn empty() -> Self { 96 | Self { 97 | packages: vec![], 98 | ..ConfigFile::default() 99 | } 100 | } 101 | 102 | pub fn print(&self, t: ConfigType) -> String { 103 | let w = ParsedConfig::from(self); 104 | match t { 105 | ConfigType::JSON => serde_json::to_string_pretty(&w).unwrap(), 106 | ConfigType::YAML => serde_yaml::to_string(&w).unwrap(), 107 | ConfigType::TOML => toml::to_string_pretty(&w).unwrap(), 108 | } 109 | } 110 | 111 | /// Creates a new [ConfigFile] from the given text 112 | /// Panics if the file cant be parsed as toml, hjson or yaml. 113 | pub async fn new(contents: &String, client: Client) -> Self { 114 | if contents.is_empty() { 115 | panic!("Empty CFG"); 116 | } 117 | 118 | // definetly not going to backfire 119 | let mut cfg = if contents.as_bytes()[0] == b'{' { 120 | // json gets brute forced first so this isnt really needed 121 | Self::parse(contents, ConfigType::JSON, client) 122 | .await 123 | .expect("Parsing CFG from JSON should work") 124 | } else if contents.len() > 3 && contents[..3] == *"---" { 125 | Self::parse(contents, ConfigType::YAML, client) 126 | .await 127 | .expect("Parsing CFG from YAML should work") 128 | } else { 129 | for i in [ConfigType::JSON, ConfigType::YAML, ConfigType::TOML].into_iter() { 130 | let res = Self::parse(contents, i, client.clone()).await; 131 | 132 | if let Ok(parsed) = res { 133 | return parsed; 134 | } 135 | 136 | println!( 137 | "{:>12} Parsing CFG from {:#?} failed: `{}` (ignore if cfg not written in {:#?})", 138 | crate::putils::warn(), 139 | i, 140 | style(res.err().unwrap()).red(), 141 | i 142 | ) 143 | } 144 | panic!("Parsing CFG failed (see above warnings to find out why)"); 145 | }; 146 | cfg.packages.sort(); 147 | cfg 148 | } 149 | 150 | pub async fn parse(txt: &str, t: ConfigType, client: Client) -> Result { 151 | ParsedConfig::parse(txt, t)?.try_into_async(client).await 152 | } 153 | 154 | pub fn into_package(self, uri: crate::archive::CompressionType) -> Result { 155 | Ok(Package::from_manifest( 156 | Manifest { 157 | version: Version::new(&self.version).parse()?, 158 | shasum: None, 159 | tarball: uri, 160 | dependencies: self.packages, 161 | }, 162 | self.name, 163 | )) 164 | } 165 | 166 | /// Creates a lockfile for this config file. 167 | /// note: Lockfiles are currently unused. 168 | pub fn lock(&mut self, cwd: &Path) -> String { 169 | let mut pkgs = vec![]; 170 | for mut p in self.collect() { 171 | if p.is_installed(cwd) { 172 | p.prepare_lock(); 173 | pkgs.push(p); 174 | }; 175 | } 176 | pkgs.sort(); 177 | serde_json::to_string_pretty(&pkgs).unwrap() 178 | } 179 | 180 | /// Iterates over all the packages (and their deps) in this config file. 181 | fn _for_each(pkgs: &mut [Package], mut cb: impl FnMut(&mut Package)) { 182 | fn inner(pkgs: &mut [Package], cb: &mut impl FnMut(&mut Package)) { 183 | for p in pkgs { 184 | cb(p); 185 | if p.has_deps() { 186 | inner(&mut p.manifest.dependencies, cb); 187 | } 188 | } 189 | } 190 | inner(pkgs, &mut cb); 191 | } 192 | 193 | /// Public wrapper for _for_each, but with the initial value filled out. 194 | pub fn for_each(&mut self, cb: impl FnMut(&mut Package)) { 195 | Self::_for_each(&mut self.packages, cb) 196 | } 197 | 198 | /// Collect all the packages, and their dependencys. 199 | /// Uses clones, because I wasn't able to get references to work 200 | pub fn collect(&mut self) -> HashSet { 201 | let mut pkgs: HashSet = HashSet::new(); 202 | self.for_each(|p| { 203 | pkgs.insert(p.clone()); 204 | }); 205 | pkgs 206 | } 207 | } 208 | 209 | #[cfg(test)] 210 | mod tests { 211 | use crate::config_file::*; 212 | 213 | #[tokio::test] 214 | async fn parse() { 215 | let t = crate::test_utils::mktemp().await; 216 | let c = t.2; 217 | let cfgs: [&mut ConfigFile; 3] = [ 218 | &mut ConfigFile::new( 219 | &r#"dependencies: { "@bendn/test": 2.0.10 }"#.into(), 220 | c.clone(), 221 | ) 222 | .await, 223 | &mut ConfigFile::new( 224 | &"dependencies:\n \"@bendn/test\": \"2.0.10\"".into(), 225 | c.clone(), 226 | ) 227 | .await, 228 | &mut ConfigFile::new( 229 | &"[dependencies]\n\"@bendn/test\" = \"2.0.10\"".into(), 230 | c.clone(), 231 | ) 232 | .await, 233 | ]; 234 | #[derive(Debug, Deserialize, Clone, Eq, PartialEq)] 235 | struct LockFileEntry { 236 | pub name: String, 237 | pub version: String, 238 | } 239 | let wanted_lockfile = serde_json::from_str::>( 240 | r#"[{"name":"@bendn/gdcli","version":"1.2.5"},{"name":"@bendn/test","version":"2.0.10"}]"#, 241 | ).unwrap(); 242 | for cfg in cfgs { 243 | assert_eq!(cfg.packages.len(), 1); 244 | assert_eq!(cfg.packages[0].to_string(), "@bendn/test@2.0.10"); 245 | assert_eq!(cfg.packages[0].manifest.dependencies.len(), 1); 246 | assert_eq!( 247 | cfg.packages[0].manifest.dependencies[0].to_string(), 248 | "@bendn/gdcli@1.2.5" 249 | ); 250 | for mut p in cfg.collect() { 251 | p.download(c.clone(), t.0.path()).await 252 | } 253 | assert_eq!( 254 | serde_json::from_str::>(cfg.lock(t.0.path()).as_str()).unwrap(), 255 | wanted_lockfile 256 | ); 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/conversions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use async_trait::async_trait; 3 | 4 | use crate::Client; 5 | 6 | #[async_trait] 7 | pub trait TryFromAsync: Sized + Send { 8 | async fn try_from_async(value: T, client: Client) -> Result; 9 | } 10 | 11 | #[async_trait] 12 | pub trait TryIntoAsync: Sized + Send { 13 | async fn try_into_async(self, client: Client) -> Result; 14 | } 15 | 16 | #[async_trait] 17 | impl TryIntoAsync for T 18 | where 19 | U: TryFromAsync, 20 | T: Send, 21 | { 22 | async fn try_into_async(self, client: Client) -> Result { 23 | U::try_from_async(self, client).await 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod archive; 2 | mod cache; 3 | mod config_file; 4 | mod conversions; 5 | mod package; 6 | mod theme; 7 | mod verbosity; 8 | 9 | use cache::Cache; 10 | use config_file::{ConfigFile, ConfigType}; 11 | use conversions::*; 12 | use package::parsing::ParsedPackage; 13 | use package::Package; 14 | 15 | use anyhow::Result; 16 | use async_recursion::async_recursion; 17 | use clap::{ColorChoice, Parser, Subcommand, ValueEnum}; 18 | use console::{self, Term}; 19 | use futures::stream::{self, StreamExt}; 20 | use indicatif::{HumanCount, HumanDuration, ProgressBar, ProgressIterator}; 21 | use lazy_static::lazy_static; 22 | use reqwest::{Client as RealClient, IntoUrl, RequestBuilder}; 23 | use std::collections::HashSet; 24 | use std::fs::{create_dir, read_dir, read_to_string, remove_dir, write}; 25 | use std::io::{stdin, Read}; 26 | use std::path::{Path, PathBuf}; 27 | use std::sync::mpsc::channel; 28 | use std::thread; 29 | use std::{env::current_dir, panic, time::Instant}; 30 | use verbosity::Verbosity; 31 | 32 | #[derive(Parser)] 33 | #[command(name = "gpm")] 34 | #[command(bin_name = "gpm")] 35 | /// A package manager for godot. 36 | struct Args { 37 | #[command(subcommand)] 38 | action: Actions, 39 | #[arg( 40 | short = 'c', 41 | long = "cfg-file", 42 | default_value = "godot.package", 43 | global = true 44 | )] 45 | /// Specify the location of the package configuration file (https://github.com/godot-package-manager#godotpackage). If -, read from stdin. 46 | config_file: PathBuf, 47 | 48 | #[arg( 49 | short = 'l', 50 | long = "lock-file", 51 | default_value = "godot.lock", 52 | global = true 53 | )] 54 | /// Specify the location of the lock file. If -, print to stdout. 55 | lock_file: PathBuf, 56 | 57 | #[arg(long = "colors", default_value = "auto", global = true)] 58 | /// Control color output. 59 | colors: ColorChoice, 60 | #[arg( 61 | long = "verbosity", 62 | short = 'v', 63 | global = true, 64 | default_value = "normal" 65 | )] 66 | /// Verbosity level. 67 | verbosity: Verbosity, 68 | #[arg( 69 | default_value = "https://registry.npmjs.org", 70 | global = true, 71 | long = "registry" 72 | )] 73 | /// Registry to use. 74 | registry: String, 75 | } 76 | 77 | #[derive(Subcommand)] 78 | enum Actions { 79 | #[clap(short_flag = 'u')] 80 | /// Downloads the latest versions of your wanted packages. 81 | Update, 82 | #[clap(short_flag = 'p')] 83 | /// Deletes all installed packages. 84 | Purge, 85 | /// Prints a tree of all the wanted packages, and their dependencies. 86 | #[command(long_about = " 87 | Print a tree of all the wanted packages, and their dependencies. 88 | Produces output like 89 | /home/my-package 90 | └── @bendn/test@2.0.10 91 | └── @bendn/gdcli@1.2.5")] 92 | Tree { 93 | #[arg(value_enum, default_value = "utf8", long = "charset")] 94 | /// Character set to print in. 95 | charset: CharSet, 96 | 97 | #[arg(value_enum, default_value = "indent", long = "prefix")] 98 | /// The prefix (indentation) of how the tree entrys are displayed. 99 | prefix: PrefixType, 100 | 101 | #[arg(long = "tarballs", default_value = "false")] 102 | /// To print download urls next to the package name. 103 | print_tarballs: bool, 104 | }, 105 | /// Helpful initializer for the godot.package file. 106 | Init { 107 | #[arg(long = "packages", num_args = 0..)] 108 | packages: Vec, 109 | }, 110 | } 111 | 112 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 113 | /// Charset for the tree subcommand. 114 | enum CharSet { 115 | /// Unicode characters (├── └──). 116 | UTF8, 117 | /// ASCII characters (|-- `--). 118 | ASCII, 119 | } 120 | 121 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] 122 | /// Prefix type for the tree subcommand. 123 | enum PrefixType { 124 | /// Indents the tree entries proportional to the depth. 125 | Indent, 126 | /// Print the depth before the entries. 127 | Depth, 128 | /// No indentation, just list. 129 | None, 130 | } 131 | 132 | #[derive(Clone)] 133 | pub struct Client { 134 | real: RealClient, 135 | cache: Cache, 136 | registry: String, 137 | } 138 | 139 | impl Client { 140 | pub fn wrap(real: RealClient, cache: Cache, registry: String) -> Self { 141 | Self { 142 | real, 143 | registry, 144 | cache, 145 | } 146 | } 147 | 148 | pub fn get(&self, url: U) -> RequestBuilder { 149 | self.real.get(url) 150 | } 151 | 152 | pub fn cache(&self) -> Cache { 153 | self.cache.clone() 154 | } 155 | 156 | pub fn cache_ref(&self) -> &Cache { 157 | &self.cache 158 | } 159 | } 160 | 161 | /// number of buffer slots 162 | const PARALLEL: usize = 6; 163 | lazy_static! { 164 | static ref BEGIN: Instant = Instant::now(); 165 | } 166 | 167 | #[tokio::main(flavor = "current_thread")] 168 | async fn main() { 169 | panic::set_hook(Box::new(|panic_info| { 170 | eprint!("{:>12} ", putils::err()); 171 | if let Some(s) = panic_info.payload().downcast_ref::<&str>() { 172 | eprint!("{s}"); 173 | } else if let Some(s) = panic_info.payload().downcast_ref::() { 174 | eprint!("{s}"); 175 | } else { 176 | eprint!("unknown"); 177 | }; 178 | if let Some(s) = panic_info.location() { 179 | eprint!(" (@{}:{})", s.file(), s.line()) 180 | } 181 | eprintln!(); 182 | })); 183 | let args = Args::parse(); 184 | fn set_colors(val: bool) { 185 | console::set_colors_enabled(val); 186 | console::set_colors_enabled_stderr(val); 187 | } 188 | match args.colors { 189 | ColorChoice::Always => set_colors(true), 190 | ColorChoice::Never => set_colors(false), 191 | ColorChoice::Auto => set_colors(Term::stdout().is_term() && Term::stderr().is_term()), 192 | } 193 | let client = mkclient(args.registry); 194 | let mut cfg = { 195 | let mut contents = String::from(""); 196 | if args.config_file == Path::new("-") { 197 | let bytes = stdin() 198 | .read_to_string(&mut contents) 199 | .expect("Stdin read should be ok"); 200 | if bytes == 0 { 201 | panic!("Stdin should not be empty"); 202 | }; 203 | } else { 204 | contents = read_to_string(args.config_file).expect("Reading config file should be ok"); 205 | }; 206 | ConfigFile::new(&contents, client.clone()).await 207 | }; 208 | fn lock(cfg: &mut ConfigFile, path: PathBuf, cwd: &Path) { 209 | let lockfile = cfg.lock(cwd); 210 | if path == Path::new("-") { 211 | println!("{lockfile}"); 212 | } else { 213 | write(path, lockfile).expect("Writing lock file should be ok"); 214 | } 215 | } 216 | let _ = BEGIN.elapsed(); // needed to initialize the instant for whatever reason 217 | let cwd = current_dir().expect("Should be able to read cwd"); 218 | match args.action { 219 | Actions::Update => { 220 | update(&mut cfg, true, args.verbosity, client.clone(), &cwd).await; 221 | lock(&mut cfg, args.lock_file, &cwd); 222 | } 223 | Actions::Purge => { 224 | purge(&mut cfg, args.verbosity, &cwd); 225 | lock(&mut cfg, args.lock_file, &cwd); 226 | } 227 | Actions::Tree { 228 | charset, 229 | prefix, 230 | print_tarballs, 231 | } => println!( 232 | "{}", 233 | tree( 234 | &mut cfg, // no locking needed 235 | charset, 236 | prefix, 237 | print_tarballs, 238 | client 239 | ) 240 | .await 241 | ), 242 | Actions::Init { packages } => { 243 | init( 244 | packages 245 | .try_into_async(client.clone()) 246 | .await 247 | .expect("Failed to parse `init` packages"), 248 | client, 249 | &cwd, 250 | ) 251 | .await 252 | .expect("Initializing cfg should be ok"); 253 | } 254 | } 255 | } 256 | 257 | pub fn mkclient(r: String) -> Client { 258 | let mut headers = reqwest::header::HeaderMap::new(); 259 | headers.insert( 260 | "User-Agent", 261 | format!( 262 | "gpm/{} (godot-package-manager/cli on GitHub)", 263 | env!("CARGO_PKG_VERSION") 264 | ) 265 | .parse() 266 | .unwrap(), 267 | ); 268 | Client::wrap( 269 | RealClient::builder() 270 | .default_headers(headers) 271 | .build() 272 | .unwrap(), 273 | Cache::new(), 274 | r, 275 | ) 276 | } 277 | 278 | async fn update(cfg: &mut ConfigFile, modify: bool, v: Verbosity, client: Client, cwd: &Path) { 279 | if !cwd.join("addons").exists() { 280 | create_dir(cwd.join("addons")).expect("Should be able to create addons folder"); 281 | } 282 | let packages = cfg.collect(); 283 | if v.debug() { 284 | println!( 285 | "collecting {} packages took {}", 286 | packages.len(), 287 | HumanDuration(BEGIN.elapsed()) 288 | ); 289 | print!("packages: ["); 290 | let mut first = true; 291 | for p in &packages { 292 | if first { 293 | print!("{p}"); 294 | } else { 295 | print!(", {p}"); 296 | } 297 | first = false; 298 | } 299 | println!("]"); 300 | } 301 | 302 | if packages.is_empty() { 303 | panic!("No packages to update (modify the \"godot.package\" file to add packages)"); 304 | } 305 | let bar; 306 | let p_count = packages.len() as u64; 307 | if v.bar() { 308 | bar = putils::bar(p_count); 309 | bar.set_prefix("Updating"); 310 | } else { 311 | bar = ProgressBar::hidden(); 312 | }; 313 | enum Status { 314 | Processing(String), 315 | Finished(String), 316 | } 317 | let bar_or_info = v.bar() || v.info(); 318 | let (tx, rx) = bar_or_info.then(channel).unzip(); 319 | let buf = stream::iter(packages) 320 | .map(|mut p| { 321 | let p_name = p.to_string(); 322 | let tx = if bar_or_info { tx.clone() } else { None }; 323 | let client = client.clone(); 324 | async move { 325 | if bar_or_info { 326 | tx.as_ref() 327 | .unwrap() 328 | .send(Status::Processing(p_name.clone())) 329 | .unwrap(); 330 | } 331 | p.download(client, cwd).await; 332 | if modify { 333 | p.modify(cwd); 334 | }; 335 | if bar_or_info { 336 | tx.unwrap().send(Status::Finished(p_name.clone())).unwrap(); 337 | } 338 | } 339 | }) 340 | .buffer_unordered(PARALLEL); 341 | // use to test the difference in speed 342 | // for mut p in packages { p.download(client.clone()).await; if modify { p.modify().unwrap(); }; bar.inc(1); } 343 | let handler = if bar_or_info { 344 | Some(thread::spawn(move || { 345 | let mut running = vec![]; 346 | let rx = rx.unwrap(); 347 | while let Ok(status) = rx.recv() { 348 | match status { 349 | Status::Processing(p) => { 350 | running.push(p); 351 | } 352 | Status::Finished(p) => { 353 | running.swap_remove(running.iter().position(|e| e == &p).unwrap()); 354 | if v.info() { 355 | bar.suspend(|| println!("{:>12} {p}", putils::green("Downloaded"))); 356 | } 357 | bar.inc(1); 358 | } 359 | } 360 | bar.set_message(running.join(", ")); 361 | } 362 | bar.finish_and_clear(); 363 | })) 364 | } else { 365 | None 366 | }; 367 | buf.for_each(|_| async {}).await; // wait till its done 368 | drop(tx); // drop the transmitter to break the reciever loop 369 | if bar_or_info { 370 | handler.unwrap().join().unwrap(); 371 | println!( 372 | "{:>12} updated {} package{} in {}", 373 | putils::green("Finished"), 374 | HumanCount(p_count), 375 | if p_count > 0 { "s" } else { "" }, 376 | HumanDuration(BEGIN.elapsed()) 377 | ) 378 | } 379 | } 380 | 381 | /// Recursively deletes empty directories. 382 | /// With this fs tree: 383 | /// ``` 384 | /// . 385 | /// `-- dir0 386 | /// |-- dir1 387 | /// `-- dir2 388 | /// ``` 389 | /// dir 1 and 2 will be deleted. 390 | /// Run multiple times to delete `dir0`. 391 | fn recursive_delete_empty(dir: &Path, cwd: &Path) -> std::io::Result<()> { 392 | if read_dir(cwd.join(dir))?.next().is_none() { 393 | return remove_dir(cwd.join(dir)); 394 | } 395 | for p in read_dir(dir)?.filter_map(|e| { 396 | let e = e.ok()?; 397 | e.file_type().ok()?.is_dir().then_some(e) 398 | }) { 399 | recursive_delete_empty(&cwd.join(dir).join(p.path()), cwd)?; 400 | } 401 | Ok(()) 402 | } 403 | 404 | fn purge(cfg: &mut ConfigFile, v: Verbosity, cwd: &Path) { 405 | let mut packages = HashSet::new(); 406 | cfg.for_each(|p| { 407 | if p.is_installed(cwd) { 408 | packages.insert(p.clone()); 409 | } 410 | }); 411 | if packages.is_empty() { 412 | if cfg.packages.is_empty() { 413 | panic!("No packages configured (modify the \"godot.package\" file to add packages)") 414 | } else { 415 | panic!("No packages installed (use \"gpm --update\" to install packages)") 416 | }; 417 | }; 418 | let p_count = packages.len() as u64; 419 | let bar; 420 | if v.bar() { 421 | bar = putils::bar(p_count); 422 | bar.set_prefix("Purging"); 423 | } else { 424 | bar = ProgressBar::hidden(); 425 | } 426 | let now = Instant::now(); 427 | packages 428 | .into_iter() 429 | .progress_with(bar.clone()) // the last steps 430 | .for_each(|p| { 431 | bar.set_message(format!("{p}")); 432 | if v.info() { 433 | bar.println(format!( 434 | "{:>12} {p} ({})", 435 | putils::green("Deleting"), 436 | p.download_dir(cwd).strip_prefix(cwd).unwrap().display(), 437 | )); 438 | } 439 | p.purge(cwd) 440 | }); 441 | 442 | // run multiple times because the algorithm goes from top to bottom, stupidly. 443 | for _ in 0..3 { 444 | if let Err(e) = recursive_delete_empty(&cwd.join("addons"), cwd) { 445 | eprintln!("{e}") 446 | } 447 | } 448 | if v.info() { 449 | println!( 450 | "{:>12} purge {} package{} in {}", 451 | putils::green("Finished"), 452 | HumanCount(p_count), 453 | if p_count > 0 { "s" } else { "" }, 454 | HumanDuration(now.elapsed()) 455 | ) 456 | } 457 | } 458 | 459 | async fn tree( 460 | cfg: &mut ConfigFile, 461 | charset: CharSet, 462 | prefix: PrefixType, 463 | print_tarballs: bool, 464 | client: Client, 465 | ) -> String { 466 | let mut tree: String = if let Ok(s) = current_dir() { 467 | format!("{}\n", s.to_string_lossy()) 468 | } else { 469 | ".\n".to_string() 470 | }; 471 | let mut count: u64 = 0; 472 | iter( 473 | &mut cfg.packages, 474 | "", 475 | &mut tree, 476 | match charset { 477 | CharSet::UTF8 => "├──", // believe it or not, these are quite unlike 478 | CharSet::ASCII => "|--", // its hard to tell, with ligatures enable 479 | }, 480 | match charset { 481 | CharSet::UTF8 => "└──", 482 | CharSet::ASCII => "`--", 483 | }, 484 | prefix, 485 | print_tarballs, 486 | 0, 487 | &mut count, 488 | client, 489 | ) 490 | .await; 491 | tree.push_str(format!("{} dependencies", HumanCount(count)).as_str()); 492 | 493 | #[async_recursion] 494 | async fn iter( 495 | packages: &mut Vec, 496 | prefix: &str, 497 | tree: &mut String, 498 | t: &str, 499 | l: &str, 500 | prefix_type: PrefixType, 501 | print_tarballs: bool, 502 | depth: u32, 503 | count: &mut u64, 504 | client: Client, 505 | ) { 506 | // the index is used to decide if the package is the last package, 507 | // so we can use a L instead of a T. 508 | let mut tmp: String; 509 | let mut index = packages.len(); 510 | *count += index as u64; 511 | for p in packages { 512 | let name = p.to_string(); 513 | index -= 1; 514 | tree.push_str( 515 | match prefix_type { 516 | PrefixType::Indent => { 517 | format!("{prefix}{} {name}", if index != 0 { t } else { l }) 518 | } 519 | PrefixType::Depth => format!("{depth} {name}"), 520 | PrefixType::None => name.to_string(), 521 | } 522 | .as_str(), 523 | ); 524 | if print_tarballs { 525 | tree.push(' '); 526 | tree.push_str(&p.manifest.tarball.to_string()); 527 | } 528 | tree.push('\n'); 529 | if p.has_deps() { 530 | iter( 531 | &mut p.manifest.dependencies, 532 | if prefix_type == PrefixType::Indent { 533 | tmp = format!("{prefix}{} ", if index != 0 { '│' } else { ' ' }); 534 | tmp.as_str() 535 | } else { 536 | "" 537 | }, 538 | tree, 539 | t, 540 | l, 541 | prefix_type, 542 | print_tarballs, 543 | depth + 1, 544 | count, 545 | client.clone(), 546 | ) 547 | .await; 548 | } 549 | } 550 | } 551 | tree 552 | } 553 | 554 | async fn init(mut packages: Vec, client: Client, cwd: &Path) -> Result<()> { 555 | let mut c = ConfigFile::empty(); 556 | if packages.is_empty() { 557 | let mut has_asked = false; 558 | let mut just_failed = false; 559 | while { 560 | if just_failed { 561 | putils::confirm("Try again?", true)? 562 | } else if !has_asked { 563 | putils::confirm("Add a package?", true)? 564 | } else { 565 | putils::confirm("Add another package?", true)? 566 | } 567 | } { 568 | has_asked = true; 569 | let p: ParsedPackage = putils::input("Package?")?; 570 | let p_name = p.to_string(); 571 | let res = p.into_package(client.clone()).await; 572 | if let Err(e) = res { 573 | putils::fail(format!("{p_name} could not be parsed: {e}").as_str())?; 574 | just_failed = true; 575 | continue; 576 | } 577 | packages.push(res.unwrap()); 578 | } 579 | }; 580 | c.packages = packages; 581 | let types = vec![ConfigType::JSON, ConfigType::YAML, ConfigType::TOML]; 582 | 583 | let mut path = Path::new(&putils::input_with_default::( 584 | "Config file save location?", 585 | "godot.package".into(), 586 | )?) 587 | .to_path_buf(); 588 | while path.exists() { 589 | if putils::confirm("This file already exists. Replace?", false)? { 590 | break; 591 | } else { 592 | path = Path::new(&putils::input::("Config file save location?")?).to_path_buf(); 593 | } 594 | } 595 | while write(&path, "").is_err() { 596 | path = Path::new(&putils::input_with_default::( 597 | "Chosen file not accessible, try again:", 598 | "godot.package".into(), 599 | )?) 600 | .to_path_buf(); 601 | } 602 | let c_text = c.print(types[putils::select(&types, "Language to save in:", 2)?]); 603 | write(path, c_text)?; 604 | if putils::confirm("Would you like to view the dependency tree?", true)? { 605 | println!( 606 | "{}", 607 | tree( 608 | &mut c, 609 | CharSet::UTF8, 610 | PrefixType::Indent, 611 | false, 612 | client.clone() 613 | ) 614 | .await 615 | ); 616 | }; 617 | 618 | if !c.packages.is_empty() 619 | && putils::confirm("Would you like to install your new packages?", true)? 620 | { 621 | update(&mut c, true, Verbosity::Normal, client.clone(), cwd).await; 622 | }; 623 | println!("Goodbye!"); 624 | Ok(()) 625 | } 626 | 627 | #[cfg(test)] 628 | mod test_utils { 629 | use glob::glob; 630 | use sha2::{Digest, Sha256}; 631 | use std::{fs::create_dir, fs::read, net::IpAddr, net::Ipv4Addr, net::SocketAddr, path::Path}; 632 | use tempfile::TempDir; 633 | use test_server::TestServer; 634 | 635 | use crate::{mkclient, Client}; 636 | type Handle = (TempDir, TestServer, Client); 637 | 638 | pub async fn mktemp() -> Handle { 639 | let tmp_dir = TempDir::new().unwrap(); 640 | create_dir(tmp_dir.path().join("addons")).unwrap(); 641 | let sock = SocketAddr::new( 642 | IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 643 | fastrand::u16(1024..65535), 644 | ); 645 | ( 646 | tmp_dir, 647 | TestServer::spawn(sock).await, 648 | mkclient(format!("http://{sock}")), 649 | ) 650 | } 651 | 652 | pub fn hashd(d: &Path) -> Vec { 653 | let mut files = glob(format!("{}/**/*", d.display()).as_str()) 654 | .unwrap() 655 | .filter_map(|s| { 656 | let p = &s.unwrap(); 657 | p.is_file().then(|| { 658 | let mut hasher = Sha256::new(); 659 | hasher.update(read(p).unwrap()); 660 | format!("{:x}", &hasher.finalize()) 661 | }) 662 | }) 663 | .collect::>(); 664 | files.sort(); 665 | files 666 | } 667 | } 668 | 669 | #[tokio::test] 670 | async fn gpm() { 671 | let t = test_utils::mktemp().await; 672 | let c = t.2; 673 | let cfg_file = 674 | &mut config_file::ConfigFile::new(&r#"packages: {"@bendn/test":2.0.10}"#.into(), c.clone()) 675 | .await; 676 | update(cfg_file, false, Verbosity::Verbose, c.clone(), t.0.path()).await; 677 | assert_eq!(test_utils::hashd(&t.0.path().join("addons")).join("|"), "1c2fd93634817a9e5f3f22427bb6b487520d48cf3cbf33e93614b055bcbd1329|8e77e3adf577d32c8bc98981f05d40b2eb303271da08bfa7e205d3f27e188bd7|a625595a71b159e33b3d1ee6c13bea9fc4372be426dd067186fe2e614ce76e3c|c5566e4fbea9cc6dbebd9366b09e523b20870b1d69dc812249fccd766ebce48e|c5566e4fbea9cc6dbebd9366b09e523b20870b1d69dc812249fccd766ebce48e|c850a9300388d6da1566c12a389927c3353bf931c4d6ea59b02beb302aac03ea|d060936e5f1e8b1f705066ade6d8c6de90435a91c51f122905a322251a181a5c|d711b57105906669572a0e53b8b726619e3a21463638aeda54e586a320ed0fc5|d794f3cee783779f50f37a53e1d46d9ebbc5ee7b37c36d7b6ee717773b6955cd|e4f9df20b366a114759282209ff14560401e316b0059c1746c979f478e363e87"); 678 | purge(cfg_file, Verbosity::Verbose, t.0.path()); 679 | assert_eq!( 680 | test_utils::hashd(&t.0.path().join("addons")), 681 | vec![] as Vec 682 | ); 683 | assert_eq!( 684 | tree( 685 | cfg_file, 686 | crate::CharSet::UTF8, 687 | crate::PrefixType::Indent, 688 | false, 689 | c.clone(), 690 | ) 691 | .await 692 | .lines() 693 | .skip(1) 694 | .collect::>() 695 | .join("\n"), 696 | "└── @bendn/test@2.0.10\n └── @bendn/gdcli@1.2.5\n2 dependencies" 697 | ); 698 | } 699 | 700 | /// Print utilities. 701 | /// Remember to use {:>12} 702 | pub mod putils { 703 | use crate::theme::BasicTheme; 704 | use console::{style, StyledObject}; 705 | use dialoguer::{theme::Theme, Confirm, Input, Select}; 706 | use indicatif::{ProgressBar, ProgressStyle}; 707 | use std::fmt; 708 | use std::io::Result; 709 | use std::str::FromStr; 710 | 711 | #[inline] 712 | pub fn err() -> StyledObject<&'static str> { 713 | style("Error").red().bold() 714 | } 715 | 716 | #[inline] 717 | pub fn select(items: &[T], p: &str, default: usize) -> Result { 718 | Select::with_theme(&BasicTheme::default()) 719 | .items(items) 720 | .with_prompt(p) 721 | .default(default) 722 | .interact() 723 | } 724 | 725 | #[inline] 726 | pub fn confirm(p: &str, default: bool) -> Result { 727 | Confirm::with_theme(&BasicTheme::default()) 728 | .with_prompt(p) 729 | .default(default) 730 | .interact() 731 | } 732 | 733 | #[inline] 734 | pub fn input(p: &str) -> Result 735 | where 736 | T: Clone + ToString + FromStr, 737 | ::Err: std::fmt::Debug + ToString, 738 | { 739 | Input::with_theme(&BasicTheme::default()) 740 | .with_prompt(p) 741 | .interact_text() 742 | } 743 | 744 | pub fn fail(message: &str) -> fmt::Result { 745 | let mut string = String::from(""); 746 | BasicTheme::default().format_error(&mut string, message)?; 747 | println!("{string}"); 748 | Ok(()) 749 | } 750 | 751 | #[inline] 752 | pub fn input_with_default(p: &str, d: T) -> Result 753 | where 754 | T: Clone + ToString + FromStr, 755 | ::Err: std::fmt::Debug + ToString, 756 | { 757 | Input::with_theme(&BasicTheme::default()) 758 | .with_prompt(p) 759 | .default(d) 760 | .interact_text() 761 | } 762 | 763 | #[inline] 764 | pub fn warn() -> StyledObject<&'static str> { 765 | style("Warn").yellow().bold() 766 | } 767 | 768 | #[inline] 769 | pub fn green(t: &str) -> StyledObject<&str> { 770 | style(t).green().bold() 771 | } 772 | 773 | #[inline] 774 | pub fn bar(len: u64) -> ProgressBar { 775 | let bar = ProgressBar::new(len); 776 | bar.set_style( 777 | ProgressStyle::with_template( 778 | "{prefix:>12.cyan.bold} [{bar:20.green}] {human_pos}/{human_len}: {wide_msg}", 779 | ) 780 | .unwrap() 781 | .progress_chars("-> "), 782 | ); 783 | bar 784 | } 785 | } 786 | -------------------------------------------------------------------------------- /src/package.rs: -------------------------------------------------------------------------------- 1 | use crate::archive::*; 2 | use crate::cache::CacheEntry; 3 | use crate::conversions::TryIntoAsync; 4 | use crate::Client; 5 | 6 | use anyhow::bail; 7 | use anyhow::{anyhow, Context, Result}; 8 | use async_recursion::async_recursion; 9 | use regex::{Captures, Regex}; 10 | use semver_rs::{Range, Version}; 11 | use serde::Serialize; 12 | use sha1::{Digest, Sha1}; 13 | use std::fs::{read_dir, read_to_string, remove_dir_all, write}; 14 | use std::path::{Path, PathBuf}; 15 | use std::str::FromStr; 16 | use std::{collections::HashMap, fmt}; 17 | 18 | pub mod parsing; 19 | use parsing::*; 20 | 21 | type DepMap = HashMap; 22 | 23 | #[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Default, Serialize, Hash)] 24 | /// The package struct. 25 | /// This struct powers the entire system, and manages 26 | /// - installation 27 | /// - modification (of the loads, so they load the right stuff) 28 | /// - removal 29 | pub struct Package { 30 | pub name: String, 31 | #[serde(skip)] 32 | pub indirect: bool, 33 | #[serde(flatten)] 34 | pub manifest: Manifest, 35 | #[serde(rename = "version")] 36 | pub _lockfile_version_string: String, // for lockfile, do not use 37 | } 38 | 39 | #[derive(Clone, Eq, Ord, PartialEq, PartialOrd, Default, Debug, Serialize, Hash)] 40 | pub struct Manifest { 41 | #[serde(skip)] 42 | pub shasum: Option, 43 | pub tarball: CompressionType, 44 | #[serde(skip)] 45 | pub dependencies: Vec, 46 | #[serde(skip)] 47 | pub version: Version, 48 | } 49 | 50 | #[macro_export] 51 | macro_rules! ctx { 52 | ($e:expr, $fmt:literal $(, $args:expr)* $(,)?) => { 53 | $e.with_context(||format!($fmt $(, $args)*)) 54 | }; 55 | } 56 | 57 | #[macro_export] 58 | macro_rules! get { 59 | ($client: expr, $fmt:literal $(, $args:expr)* $(,)?) => { 60 | $client.get(&format!($fmt $(, $args)*)).send().await 61 | }; 62 | } 63 | 64 | impl Package { 65 | pub fn prepare_lock(&mut self) { 66 | self.manifest.tarball.lock() 67 | } 68 | 69 | pub fn from_manifest(m: Manifest, name: String) -> Self { 70 | Self { 71 | _lockfile_version_string: m.version.to_string(), 72 | manifest: m, 73 | name, 74 | ..Default::default() 75 | } 76 | } 77 | 78 | #[inline] 79 | /// Does this package have dependencies? 80 | pub fn has_deps(&mut self) -> bool { 81 | !self.manifest.dependencies.is_empty() 82 | } 83 | 84 | /// Creates a new [Package] from a name and version. 85 | /// Makes network calls to get the manifest (which makes network calls to get dependency manifests) (unless cached) 86 | #[async_recursion] 87 | pub async fn new(name: String, version: String, client: Client) -> Result { 88 | let version = version.trim(); 89 | if version.is_empty() { 90 | // i forgot what this is for 91 | return Self::new_no_version(name, client).await; 92 | } 93 | let r = ctx!( 94 | Range::new(version).parse(), 95 | "parsing version range {version} for {name}" 96 | )?; // this does ~ and ^ and >= and < and || e.q parsing 97 | if name.starts_with("http") { 98 | return Self::get_tarball(name, version.to_owned(), &r, client).await; 99 | } 100 | 101 | if let Some(got) = client.cache().get_mut(&name) { 102 | let mut vers = got.clone(); // clone to remove references to dashmap 103 | drop(got); // drop reference (let x = x doesnt drop original x until scope ends) 104 | if let Some(mut find) = vers.find_version(&r) { 105 | // find is a reference to vers which is cloned (not ref to dashmap) 106 | // this block was supposed to be 107 | // Ok(find.parse(...).await?.get_package()) 108 | // but then it deadlocked because get_package() would recurse 109 | find.parse(client.clone(), name.clone()).await?; 110 | let p = find.get_package(); 111 | client.cache_ref().insert( 112 | name, 113 | find.key().clone(), 114 | std::mem::take(find.value_mut()), 115 | ); // cloned find, must now replace 116 | return Ok(p); 117 | }; 118 | } 119 | let packument = ctx!( 120 | Self::get_packument(client.clone(), &name).await, 121 | "getting packument for {name}" 122 | )?; 123 | let mut versions = { 124 | let mut e = client.cache_ref().entry(name.clone()).or_default(); 125 | // clone to not have references to dashmap which causes deadlock 126 | // this does (should) still insert the packument in the real cache 127 | e.insert_packument(packument).clone() 128 | }; 129 | // do it again with the new entrys inserted 130 | if let Some(mut find) = versions.find_version(&r) { 131 | find.parse(client.clone(), name.clone()).await?; 132 | let p = find.get_package(); 133 | client 134 | .cache() 135 | .insert(name, find.key().clone(), std::mem::take(find.value_mut())); 136 | return Ok(p); 137 | } 138 | bail!( 139 | "Failed to match version for package {name} matching {version}. Tried versions: {:?}", 140 | versions 141 | ); 142 | } 143 | 144 | /// Create a package from a [str]. see also [ParsedPackage]. 145 | #[allow(dead_code)] // used for tests 146 | pub async fn create_from_str(s: &str, client: Client) -> Result { 147 | ParsedPackage::from_str(s) 148 | .unwrap() 149 | .into_package(client) 150 | .await 151 | } 152 | 153 | pub async fn get_tarball( 154 | uri: String, 155 | version: String, 156 | range: &Range, 157 | client: Client, 158 | ) -> Result { 159 | if let Some(mut v) = client.cache().get_mut(&uri) { 160 | if let Some(e) = v.find_version(range) { 161 | return Ok(e.get_package()); // no recursion, very safe 162 | } 163 | } 164 | 165 | let resp = ctx!(get!(client.clone(), "{uri}"), "getting tarball {uri}")?; 166 | let ty = uri.split('.').last().unwrap_or("zip"); 167 | let bytes = resp.bytes().await?.to_vec(); 168 | let mut entry = CacheEntry::from(CompressionType::from(ty, bytes, uri.clone())); 169 | entry.parse(client.clone(), uri.clone()).await?; 170 | let p = entry.get_package(); 171 | client.cache().insert(uri, version.clone(), entry); 172 | Ok(p) 173 | } 174 | 175 | /// Creates a new [Package] from a name, gets the latest version from registry/name. 176 | pub async fn new_no_version(name: String, client: Client) -> Result { 177 | const MARKER: &str = "🐢"; // latest 178 | if let Some(n) = client.cache().get(&name) { 179 | if let Some(marker) = n.get(MARKER) { 180 | return Ok(marker.get_package()); // doesnt recurse 181 | } 182 | } 183 | let resp = get!(client.clone(), "{}/{name}/latest", client.registry)? 184 | .text() 185 | .await?; 186 | if resp == "\"Not Found\"" { 187 | return Err(anyhow!("Package {name} was not found")); 188 | }; 189 | let resp: Manifest = serde_json::from_str::(&resp)? 190 | .try_into_async(client.clone()) 191 | .await?; 192 | let latest = Package { 193 | name: name.to_owned(), 194 | _lockfile_version_string: resp.version.to_string(), 195 | manifest: resp, 196 | ..Default::default() 197 | }; 198 | client 199 | .cache() 200 | .insert(name, MARKER.to_owned(), latest.clone().into()); 201 | Ok(latest) 202 | } 203 | 204 | /// Returns wether this package is installed. 205 | pub fn is_installed(&self, cwd: &Path) -> bool { 206 | self.download_dir(cwd).exists() 207 | } 208 | 209 | /// Deletes this [Package]. 210 | pub fn purge(&self, cwd: &Path) { 211 | if self.is_installed(cwd) { 212 | remove_dir_all(self.download_dir(cwd)).expect("Should be able to remove download dir"); 213 | } 214 | } 215 | 216 | /// Installs this [Package] to a download directory, 217 | /// depending on wether this package is a direct dependency or not. 218 | pub async fn download(&mut self, client: Client, cwd: &Path) { 219 | self.purge(cwd); 220 | let bytes = get!(client.clone(), "{}", &self.manifest.tarball) 221 | .expect("Tarball download should work") 222 | .bytes() 223 | .await 224 | .unwrap() 225 | .to_vec(); 226 | 227 | let mut hasher = Sha1::new(); 228 | hasher.update(&bytes); 229 | if let Some(sha) = &self.manifest.shasum { 230 | assert_eq!( 231 | sha, 232 | &format!("{:x}", hasher.finalize()), 233 | "Tarball did not match checksum!" 234 | ); 235 | } 236 | // println!( 237 | // "(\"{}\", hex::decode(\"{}\").unwrap()),", 238 | // self.manifest.tarball.replace(&(client.registry + "/"), ""), 239 | // hex::encode(&bytes) 240 | // ); 241 | let ty = match self.manifest.tarball.clone() { 242 | CompressionType::Gzip(_) => CompressionType::Gzip(Data::new_bytes(bytes)), 243 | CompressionType::Zip(_) => CompressionType::Zip(Data::new_bytes(bytes)), 244 | _ => unreachable!(), 245 | }; 246 | Archive::new(ty) 247 | .unwrap() 248 | .unpack(&self.download_dir(cwd)) 249 | .expect("Tarball should unpack"); 250 | } 251 | 252 | pub async fn get_packument(client: Client, name: &str) -> Result { 253 | let resp = ctx!( 254 | get!(client.clone(), "{}/{name}", client.registry)? 255 | .text() 256 | .await, 257 | "getting packument from {}/{name}", 258 | client.registry 259 | )?; 260 | if resp == "\"Not Found\"" { 261 | return Err(anyhow!("Package {name} was not found",)); 262 | }; 263 | let res = ctx!( 264 | serde_json::from_str::(&resp), 265 | "parsing packument from {}/{name}", 266 | client.registry 267 | )?; 268 | // println!( 269 | // "(\"{name}\", r#\"{}\"#),", 270 | // serde_json::to_string(&res) 271 | // .unwrap() 272 | // .replace("https://registry.npmjs.org", "{REGISTRY}") 273 | // ); 274 | Ok(res.into()) 275 | } 276 | 277 | /// Returns the download directory for this package depending on wether it is indirect or not. 278 | pub fn download_dir(&self, cwd: &Path) -> PathBuf { 279 | if self.indirect { 280 | self.indirect_download_dir(cwd) 281 | } else { 282 | self.direct_download_dir(cwd) 283 | } 284 | } 285 | 286 | /// The download directory if this package is a direct dep. 287 | fn direct_download_dir(&self, cwd: &Path) -> PathBuf { 288 | cwd.join("addons").join(self.name.clone()) 289 | } 290 | 291 | /// The download directory if this package is a indirect dep. 292 | fn indirect_download_dir(&self, cwd: &Path) -> PathBuf { 293 | cwd.join("addons") 294 | .join("__gpm_deps") 295 | .join(self.name.clone()) 296 | .join(self.manifest.version.to_string()) 297 | } 298 | } 299 | 300 | // package modification block 301 | impl Package { 302 | /// Modifies the loads of a GDScript script. 303 | /// ```gdscript 304 | /// extends Node 305 | /// 306 | /// const Wow = preload("res://addons/my_awesome_addon/wow.gd") 307 | /// ``` 308 | /// => 309 | /// ```gdscript 310 | /// # --snip-- 311 | /// const Wow = preload("res://addons/__gpm_deps/my_awesome_addon/wow.gd") 312 | /// ``` 313 | fn modify_script_loads(&self, t: &str, cwd: &Path, dep_map: &DepMap) -> String { 314 | lazy_static::lazy_static! { 315 | static ref SCRIPT_LOAD_R: Regex = Regex::new("(pre)?load\\([\"']([^)]+)['\"]\\)").unwrap(); 316 | } 317 | SCRIPT_LOAD_R 318 | .replace_all(t, |c: &Captures| { 319 | let p = Path::new(c.get(2).unwrap().as_str()); 320 | let res = self.modify_load(p.strip_prefix("res://").unwrap_or(p), cwd, dep_map); 321 | let preloaded = if c.get(1).is_some() { "pre" } else { "" }; 322 | if res == p { 323 | format!("{preloaded}load('{}')", p.display()) 324 | } else { 325 | format!("{preloaded}load('res://{}')", res.display()) 326 | } 327 | }) 328 | .to_string() 329 | } 330 | 331 | /// Modifies the loads of a godot TextResource. 332 | /// ```gdresource 333 | /// [gd_scene load_steps=1 format=2] 334 | /// 335 | /// [ext_resource path="res://addons/my_awesome_addon/wow.gd" type="Script" id=1] 336 | /// ``` 337 | /// => 338 | /// ```gdresource 339 | /// --snip-- 340 | /// [ext_resource path="res://addons/__gpm_deps/my_awesome_addon/wow.gd" type="Script" id=1] 341 | /// ``` 342 | fn modify_tres_loads(&self, t: &str, cwd: &Path, dep_map: &DepMap) -> String { 343 | lazy_static::lazy_static! { 344 | static ref TRES_LOAD_R: Regex = Regex::new(r#"\[ext_resource path="([^"]+)""#).unwrap(); 345 | } 346 | TRES_LOAD_R 347 | .replace_all(t, |c: &Captures| { 348 | let p = Path::new(c.get(1).unwrap().as_str()); 349 | let res = self.modify_load( 350 | p.strip_prefix("res://") 351 | .expect("TextResource path should be absolute"), 352 | cwd, 353 | dep_map, 354 | ); 355 | if res == p { 356 | format!(r#"[ext_resource path="{}""#, p.display()) 357 | } else { 358 | format!(r#"[ext_resource path="res://{}""#, res.display()) 359 | } 360 | }) 361 | .to_string() 362 | } 363 | 364 | /// The backend for modify_script_loads and modify_tres_loads. 365 | fn modify_load(&self, path: &Path, cwd: &Path, dep_map: &DepMap) -> PathBuf { 366 | // if it works, skip it 367 | if path.exists() || cwd.join(path).exists() { 368 | return path.to_path_buf(); 369 | } 370 | if let Some(c) = path.components().nth(1) { 371 | if let Some(addon_dir) = dep_map.get(&String::from(c.as_os_str().to_str().unwrap())) { 372 | let wanted_f = 373 | Path::new(addon_dir).join(path.components().skip(2).collect::()); 374 | return wanted_f; 375 | } 376 | }; 377 | eprintln!( 378 | "{:>12} Could not find path for {path:#?}", 379 | crate::putils::warn() 380 | ); 381 | path.to_path_buf() 382 | } 383 | 384 | /// Recursively modifies a directory. 385 | fn recursive_modify(&self, dir: PathBuf, dep_map: &DepMap) -> Result<()> { 386 | for entry in read_dir(&dir)? { 387 | let p = entry?; 388 | if p.path().is_dir() { 389 | self.recursive_modify(p.path(), dep_map)?; 390 | continue; 391 | } 392 | 393 | #[derive(PartialEq, Debug)] 394 | enum Type { 395 | TextResource, 396 | GDScript, 397 | } 398 | if let Some(e) = p.path().extension() { 399 | let t = if e == "tres" || e == "tscn" { 400 | Type::TextResource 401 | } else if e == "gd" || e == "gdscript" { 402 | Type::GDScript 403 | } else { 404 | continue; 405 | }; 406 | let text = read_to_string(p.path())?; 407 | write( 408 | p.path(), 409 | match t { 410 | Type::TextResource => self.modify_tres_loads(&text, &dir, dep_map), 411 | Type::GDScript => self.modify_script_loads(&text, &dir, dep_map), 412 | }, 413 | )?; 414 | } 415 | } 416 | Ok(()) 417 | } 418 | 419 | fn dep_map(&mut self, cwd: &Path) -> Result { 420 | let mut dep_map = HashMap::::new(); 421 | fn add(p: &Package, dep_map: &mut DepMap, cwd: &Path) -> Result<()> { 422 | let d = p.download_dir(cwd); 423 | dep_map.insert(p.name.clone(), d.clone()); 424 | // unscoped (@ben/cli => cli) (for compat) 425 | if let Some((_, s)) = p.name.split_once('/') { 426 | dep_map.insert(s.into(), d); 427 | } 428 | Ok(()) 429 | } 430 | for pkg in &self.manifest.dependencies { 431 | add(pkg, &mut dep_map, cwd)?; 432 | } 433 | add(self, &mut dep_map, cwd)?; 434 | Ok(dep_map) 435 | } 436 | 437 | /// The catalyst for `recursive_modify`. 438 | pub fn modify(&mut self, cwd: &Path) { 439 | if !self.is_installed(cwd) { 440 | panic!("Attempting to modify a package that is not installed"); 441 | } 442 | 443 | let map = &self.dep_map(cwd).unwrap(); 444 | self.recursive_modify(self.download_dir(cwd), map).unwrap(); 445 | } 446 | } 447 | 448 | impl fmt::Display for Package { 449 | /// Stringifies this [Package], format my_p@1.0.0. 450 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 451 | write!(f, "{}@{}", self.name, self.manifest.version) 452 | } 453 | } 454 | 455 | impl fmt::Debug for Package { 456 | /// Mirrors the [Display] impl. 457 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 458 | fmt::Display::fmt(&self, f) 459 | } 460 | } 461 | 462 | #[cfg(test)] 463 | mod tests { 464 | use crate::package::*; 465 | 466 | #[tokio::test] 467 | async fn download() { 468 | let t = crate::test_utils::mktemp().await; 469 | let c = t.2; 470 | let mut p = Package::create_from_str("@bendn/test:2.0.10", c.clone()) 471 | .await 472 | .unwrap(); 473 | p.download(c.clone(), t.0.path()).await; 474 | assert_eq!( 475 | crate::test_utils::hashd(&p.download_dir(t.0.path())), 476 | [ 477 | "1c2fd93634817a9e5f3f22427bb6b487520d48cf3cbf33e93614b055bcbd1329", // readme.md 478 | "c5566e4fbea9cc6dbebd9366b09e523b20870b1d69dc812249fccd766ebce48e", // sub1.gd 479 | "c5566e4fbea9cc6dbebd9366b09e523b20870b1d69dc812249fccd766ebce48e", // sub2.gd 480 | "d711b57105906669572a0e53b8b726619e3a21463638aeda54e586a320ed0fc5", // main.gd 481 | "e4f9df20b366a114759282209ff14560401e316b0059c1746c979f478e363e87", // package.json 482 | ] 483 | ); 484 | } 485 | 486 | #[tokio::test] 487 | async fn dep_map() { 488 | // no fs was touched in the making of this test 489 | 490 | assert_eq!( 491 | Package::create_from_str("@bendn/test@2.0.10", crate::test_utils::mktemp().await.2) 492 | .await 493 | .unwrap() 494 | .dep_map(Path::new("")) 495 | .unwrap(), 496 | HashMap::from([ 497 | ("test".into(), "addons/@bendn/test".into()), 498 | ("@bendn/test".into(), "addons/@bendn/test".into()), 499 | ( 500 | "@bendn/gdcli".into(), 501 | "addons/__gpm_deps/@bendn/gdcli/1.2.5".into() 502 | ), 503 | ( 504 | "gdcli".into(), 505 | "addons/__gpm_deps/@bendn/gdcli/1.2.5".into() 506 | ), 507 | ]) 508 | ); 509 | } 510 | 511 | #[tokio::test] 512 | async fn modify_load() { 513 | let t = crate::test_utils::mktemp().await; 514 | let c = t.2; 515 | let mut p = Package::create_from_str("@bendn/test=2.0.10", c.clone()) 516 | .await 517 | .unwrap(); 518 | let dep_map = &p.dep_map(t.0.path()).unwrap(); 519 | p.download(c, t.0.path()).await; 520 | p.indirect = false; 521 | let cwd = t.0.path().join("addons/@bendn/test"); 522 | assert_eq!( 523 | Path::new( 524 | p.modify_load(Path::new("addons/test/main.gd"), &cwd, dep_map) 525 | .to_str() 526 | .unwrap() 527 | ), 528 | t.0.path().join("addons/@bendn/test/main.gd") 529 | ); 530 | 531 | // dependency usage test 532 | assert_eq!( 533 | Path::new( 534 | p.modify_load(Path::new("addons/gdcli/Parser.gd"), &cwd, dep_map) 535 | .to_str() 536 | .unwrap() 537 | ), 538 | t.0.path() 539 | .join("addons/__gpm_deps/@bendn/gdcli/1.2.5/Parser.gd") 540 | ) 541 | } 542 | } 543 | -------------------------------------------------------------------------------- /src/package/parsing.rs: -------------------------------------------------------------------------------- 1 | use crate::archive::*; 2 | use crate::conversions::*; 3 | use crate::package::{Manifest, Package}; 4 | use crate::Client; 5 | use anyhow::{anyhow, Result}; 6 | use async_trait::async_trait; 7 | use futures::stream::{self, StreamExt}; 8 | use semver_rs::Version; 9 | use serde::{Deserialize, Serialize}; 10 | use std::{collections::HashMap, fmt}; 11 | 12 | #[derive(Clone, Debug, Default)] 13 | pub struct ParsedPackage { 14 | pub name: String, 15 | pub version: VersionType, 16 | } 17 | 18 | #[derive(Clone, Debug, Default)] 19 | pub enum VersionType { 20 | /// Normal version, just use it 21 | Normal(String), 22 | /// Abstract version, figure it out later 23 | #[default] 24 | Latest, 25 | } 26 | 27 | impl fmt::Display for VersionType { 28 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 29 | write!( 30 | f, 31 | "{}", 32 | match self { 33 | VersionType::Normal(v) => v, 34 | VersionType::Latest => "latest", 35 | } 36 | ) 37 | } 38 | } 39 | 40 | impl ParsedPackage { 41 | /// Turn into a [Package]. 42 | pub async fn into_package(self, client: Client) -> Result { 43 | match self.version { 44 | VersionType::Normal(v) => Package::new(self.name, v, client).await, 45 | VersionType::Latest => Package::new_no_version(self.name, client).await, 46 | } 47 | } 48 | } 49 | 50 | impl fmt::Display for ParsedPackage { 51 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 52 | write!(f, "{}@{}", self.name, self.version) 53 | } 54 | } 55 | 56 | impl std::str::FromStr for ParsedPackage { 57 | type Err = anyhow::Error; 58 | 59 | /// Supports 3 version syntax variations: `:`, `=`, `@`, if version not specified, will fetch latest. 60 | /// see https://docs.npmjs.com/cli/v7/configuring-npm/package-json#name 61 | fn from_str(s: &str) -> Result { 62 | #[inline] 63 | fn not_too_long(s: &str) -> bool { 64 | s.len() < 214 65 | } 66 | #[inline] 67 | fn safe(s: &str) -> bool { 68 | s.find([ 69 | ' ', '<', '>', '[', ']', '{', '}', '|', '\\', '^', '%', ':', '=', 70 | ]) 71 | .is_none() 72 | } 73 | fn check(s: &str) -> Result<()> { 74 | if not_too_long(s) && safe(s) { 75 | Ok(()) 76 | } else { 77 | Err(anyhow!("Invalid package name")) 78 | } 79 | } 80 | 81 | fn split_p(s: &str, d: char) -> Result { 82 | let Some((p, v)) = s.split_once(d) else { 83 | check(s)?; 84 | return Ok(ParsedPackage {name: s.to_string(), version: VersionType::Latest }); 85 | }; 86 | check(p)?; 87 | Ok(ParsedPackage { 88 | name: p.to_string(), 89 | version: VersionType::Normal(v.to_string()), 90 | }) 91 | } 92 | if s.contains(':') { 93 | // @bendn/gdcli:1.2.5 94 | split_p(s, ':') 95 | } else if s.contains('=') { 96 | // @bendn/gdcli=1.2.5 97 | return split_p(s, '='); 98 | } else { 99 | // @bendn/gdcli@1.2.5 100 | if s.as_bytes()[0] == b'@' { 101 | let mut owned_s = s.to_string(); 102 | owned_s.remove(0); 103 | let Some((p, v)) = owned_s.split_once('@') else { 104 | check(s)?; 105 | return Ok(ParsedPackage {name: s.to_string(), version: VersionType::Latest }); 106 | }; 107 | check(&format!("@{p}")[..])?; 108 | return Ok(ParsedPackage { 109 | name: format!("@{p}"), 110 | version: VersionType::Normal(v.to_string()), 111 | }); 112 | } 113 | return split_p(s, '@'); 114 | } 115 | } 116 | } 117 | 118 | #[derive(Clone, Default, Deserialize, Serialize)] 119 | pub struct ParsedManifest { 120 | pub dist: ParsedManifestDist, 121 | #[serde(default)] 122 | pub dependencies: HashMap, 123 | pub version: String, 124 | } 125 | 126 | impl fmt::Debug for ParsedManifest { 127 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 128 | write!(f, "ParsedManifest<{:?}>", self.dependencies) 129 | } 130 | } 131 | 132 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 133 | pub struct ParsedManifestDist { 134 | pub shasum: String, 135 | pub tarball: String, 136 | } 137 | 138 | #[async_trait::async_trait] 139 | impl TryFromAsync for Manifest { 140 | async fn try_from_async(value: ParsedManifest, client: Client) -> Result { 141 | Ok(Manifest { 142 | shasum: Some(value.dist.shasum), 143 | tarball: CompressionType::Gzip(Data::new_uri(value.dist.tarball)), 144 | version: Version::new(&value.version).parse()?, 145 | dependencies: value.dependencies.try_into_async(client).await?, 146 | }) 147 | } 148 | } 149 | 150 | #[derive(Serialize)] 151 | pub struct Packument { 152 | pub versions: Vec, // note: unprocessed manifests because we dont want to make requests for versions we dont need 153 | } 154 | 155 | #[derive(Clone, Default, Debug, Deserialize, Serialize)] 156 | pub struct ParsedPackument { 157 | pub versions: HashMap, 158 | } 159 | 160 | impl From for Packument { 161 | fn from(val: ParsedPackument) -> Self { 162 | let mut versions: Vec = val.versions.into_values().collect(); 163 | // sort newest first (really badly) 164 | versions.sort_unstable_by(|a, b| { 165 | Version::new(&b.version) 166 | .parse() 167 | .unwrap() 168 | .cmp(&Version::new(&a.version).parse().unwrap()) 169 | }); 170 | Packument { versions } 171 | } 172 | } 173 | 174 | #[async_trait] 175 | impl TryFromAsync> for Vec { 176 | async fn try_from_async( 177 | value: HashMap, 178 | client: Client, 179 | ) -> Result> { 180 | stream::iter(value.into_iter()) 181 | .map(|(name, version)| async { 182 | let client = client.clone(); 183 | async move { 184 | let mut r = Package::new(name.clone(), version.clone(), client).await; 185 | if let Ok(p) = &mut r { 186 | p.indirect = true; 187 | } 188 | r 189 | } 190 | .await 191 | }) 192 | .buffer_unordered(crate::PARALLEL) 193 | .collect::>>() 194 | .await 195 | .into_iter() 196 | .collect() 197 | } 198 | } 199 | 200 | #[async_trait] 201 | impl TryFromAsync> for Vec { 202 | async fn try_from_async(value: Vec, client: Client) -> Result> { 203 | stream::iter(value.into_iter()) 204 | .map(|pp| async { 205 | let client = client.clone(); 206 | async move { pp.into_package(client).await }.await 207 | }) 208 | .buffer_unordered(crate::PARALLEL) 209 | .collect::>>() 210 | .await 211 | .into_iter() 212 | .collect() 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use console::{style, Style, StyledObject}; 2 | use dialoguer::theme::Theme; 3 | use std::fmt; 4 | 5 | pub struct BasicTheme { 6 | pub defaults_style: Style, 7 | pub prompt_style: Style, 8 | pub prompt_prefix: StyledObject, 9 | pub success_prefix: StyledObject, 10 | pub error_prefix: StyledObject, 11 | pub error_style: Style, 12 | pub hint_style: Style, 13 | pub values_style: Style, 14 | pub active_item_style: Style, 15 | pub inactive_item_style: Style, 16 | pub active_item_prefix: StyledObject, 17 | pub inactive_item_prefix: StyledObject, 18 | } 19 | 20 | impl Default for BasicTheme { 21 | fn default() -> BasicTheme { 22 | BasicTheme { 23 | defaults_style: Style::new().for_stderr().green().bold(), 24 | prompt_style: Style::new().for_stderr().bold(), 25 | prompt_prefix: style("?".to_string()).for_stderr().yellow(), 26 | success_prefix: style("+".to_string()).for_stderr().green(), 27 | error_prefix: style("-".to_string()).for_stderr().red(), 28 | error_style: Style::new().for_stderr().red().bold().italic(), 29 | hint_style: Style::new().for_stderr().blue().dim(), 30 | values_style: Style::new().for_stderr().green(), 31 | active_item_style: Style::new().for_stderr().cyan(), 32 | inactive_item_style: Style::new().for_stderr(), 33 | active_item_prefix: style(">".to_string()).for_stderr().green(), 34 | inactive_item_prefix: style(" ".to_string()).for_stderr(), 35 | } 36 | } 37 | } 38 | 39 | impl Theme for BasicTheme { 40 | /// Formats a prompt. 41 | fn format_prompt(&self, f: &mut dyn fmt::Write, prompt: &str) -> fmt::Result { 42 | if !prompt.is_empty() { 43 | write!( 44 | f, 45 | "{} {}", 46 | &self.prompt_prefix, 47 | self.prompt_style.apply_to(prompt) 48 | )? 49 | } 50 | Ok(()) 51 | } 52 | 53 | /// Formats an error 54 | fn format_error(&self, f: &mut dyn fmt::Write, err: &str) -> fmt::Result { 55 | write!( 56 | f, 57 | "{} {}", 58 | &self.error_prefix, 59 | self.error_style.apply_to(err) 60 | ) 61 | } 62 | 63 | /// Formats an input prompt. 64 | fn format_input_prompt( 65 | &self, 66 | f: &mut dyn fmt::Write, 67 | prompt: &str, 68 | default: Option<&str>, 69 | ) -> fmt::Result { 70 | if !prompt.is_empty() { 71 | write!( 72 | f, 73 | "{} {}", 74 | &self.prompt_prefix, 75 | self.prompt_style.apply_to(prompt) 76 | )?; 77 | } 78 | 79 | match default { 80 | Some(default) => write!( 81 | f, 82 | " {} ", 83 | self.defaults_style.apply_to(&format!("({})", default)), 84 | ), 85 | None => write!(f, " "), 86 | } 87 | } 88 | 89 | /// Formats a confirm prompt. 90 | fn format_confirm_prompt( 91 | &self, 92 | f: &mut dyn fmt::Write, 93 | prompt: &str, 94 | default: Option, 95 | ) -> fmt::Result { 96 | if !prompt.is_empty() { 97 | write!( 98 | f, 99 | "{} {} ", 100 | &self.prompt_prefix, 101 | self.prompt_style.apply_to(prompt) 102 | )?; 103 | } 104 | 105 | match default { 106 | None => write!(f, "{} ", self.hint_style.apply_to("(y/n)"),), 107 | Some(true) => write!( 108 | f, 109 | "({}{})", 110 | self.defaults_style.apply_to("y"), 111 | self.hint_style.apply_to("/n") 112 | ), 113 | Some(false) => write!( 114 | f, 115 | "({}{})", 116 | self.hint_style.apply_to("y/"), 117 | self.defaults_style.apply_to("n") 118 | ), 119 | } 120 | } 121 | 122 | /// Formats a confirm prompt after selection. 123 | fn format_confirm_prompt_selection( 124 | &self, 125 | f: &mut dyn fmt::Write, 126 | prompt: &str, 127 | selection: Option, 128 | ) -> fmt::Result { 129 | if !prompt.is_empty() { 130 | write!( 131 | f, 132 | "{} {}", 133 | &self.success_prefix, 134 | self.prompt_style.apply_to(prompt) 135 | )?; 136 | } 137 | let selection = selection.map(|b| if b { "yes" } else { "no" }); 138 | 139 | match selection { 140 | Some(selection) => write!(f, " {}", self.values_style.apply_to(selection)), 141 | None => Ok(()), 142 | } 143 | } 144 | 145 | /// Formats an input prompt after selection. 146 | fn format_input_prompt_selection( 147 | &self, 148 | f: &mut dyn fmt::Write, 149 | prompt: &str, 150 | sel: &str, 151 | ) -> fmt::Result { 152 | if !prompt.is_empty() { 153 | write!( 154 | f, 155 | "{} {} ", 156 | &self.success_prefix, 157 | self.prompt_style.apply_to(prompt) 158 | )?; 159 | } 160 | 161 | write!(f, "{}", self.values_style.apply_to(sel)) 162 | } 163 | 164 | /// Formats a select prompt item. 165 | fn format_select_prompt_item( 166 | &self, 167 | f: &mut dyn fmt::Write, 168 | text: &str, 169 | active: bool, 170 | ) -> fmt::Result { 171 | let details = if active { 172 | ( 173 | &self.active_item_prefix, 174 | self.active_item_style.apply_to(text), 175 | ) 176 | } else { 177 | ( 178 | &self.inactive_item_prefix, 179 | self.inactive_item_style.apply_to(text), 180 | ) 181 | }; 182 | 183 | write!(f, "{} {}", details.0, details.1) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/verbosity.rs: -------------------------------------------------------------------------------- 1 | use clap::{builder::PossibleValue, ValueEnum}; 2 | 3 | #[derive(Clone, Debug, Eq, PartialEq, Copy)] 4 | pub enum Verbosity { 5 | Silent, 6 | Quiet, 7 | Normal, 8 | Verbose, 9 | // VeryVerbose, 10 | } 11 | 12 | impl Default for Verbosity { 13 | fn default() -> Self { 14 | Self::Normal 15 | } 16 | } 17 | 18 | impl std::str::FromStr for Verbosity { 19 | type Err = String; 20 | 21 | fn from_str(s: &str) -> Result { 22 | for variant in Self::value_variants() { 23 | if variant.to_possible_value().unwrap().matches(s, false) { 24 | return Ok(*variant); 25 | } 26 | } 27 | Err(format!("Invalid variant: {}", s)) 28 | } 29 | } 30 | 31 | impl Verbosity { 32 | #[inline] 33 | pub fn bar(&self) -> bool { 34 | self == &Self::Normal || self == &Self::Verbose 35 | } 36 | 37 | #[inline] 38 | pub fn info(&self) -> bool { 39 | self == &Self::Normal || self == &Self::Verbose 40 | } 41 | 42 | #[inline] 43 | pub fn debug(&self) -> bool { 44 | self == &Self::Verbose 45 | } 46 | } 47 | 48 | impl ValueEnum for Verbosity { 49 | fn value_variants<'a>() -> &'a [Self] { 50 | &[Self::Silent, Self::Quiet, Self::Normal, Self::Verbose] 51 | } 52 | 53 | fn to_possible_value(&self) -> Option { 54 | Some(match self { 55 | Self::Silent => PossibleValue::new("silent"), 56 | Self::Quiet => PossibleValue::new("quiet"), 57 | Self::Normal => PossibleValue::new("normal"), 58 | Self::Verbose => PossibleValue::new("verbose"), 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test-server" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | axum = "0.6.18" 8 | hex = "0.4.3" 9 | lazy_static = "1.4.0" 10 | tokio = { version = "1.28.1", features = ["macros", "rt-multi-thread"] } 11 | 12 | [dev-dependencies] 13 | reqwest = "0.11" 14 | -------------------------------------------------------------------------------- /test-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | mod data; 2 | use axum::response::{IntoResponse, Response}; 3 | use axum::{extract::Path, routing::get, Router}; 4 | use data::{META, TARBALLS}; 5 | use std::net::SocketAddr; 6 | use std::thread; 7 | pub use thread::JoinHandle; 8 | pub struct TestServer(JoinHandle<()>); 9 | 10 | impl TestServer { 11 | pub async fn spawn_blocking(addr: SocketAddr) { 12 | let app = Router::new().route("/*all", get(move |p| ret(p, addr))); 13 | axum::Server::bind(&addr) 14 | .serve(app.into_make_service()) 15 | .await 16 | .unwrap(); 17 | } 18 | 19 | pub async fn spawn(addr: SocketAddr) -> TestServer { 20 | let handle = thread::spawn(move || { 21 | tokio::runtime::Builder::new_current_thread() 22 | .enable_all() 23 | .build() 24 | .unwrap() 25 | .block_on(async move { 26 | Self::spawn_blocking(addr).await; 27 | }) 28 | }); 29 | TestServer { 0: handle } 30 | } 31 | } 32 | 33 | async fn ret(Path(params): Path, addr: SocketAddr) -> Response { 34 | if let Some(meta) = META.get(params.as_str()) { 35 | meta.replace("{REGISTRY}", &format!("http://{addr}")) 36 | .into_response() 37 | } else if let Some(tarball) = TARBALLS.get(params.as_str()) { 38 | tarball.clone().into_response() 39 | } else { 40 | "Not Found".into_response() 41 | } 42 | } 43 | 44 | #[tokio::test] 45 | async fn works() { 46 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; 47 | let server = TestServer::spawn(SocketAddr::new( 48 | IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 49 | 8080, 50 | )) 51 | .await; 52 | assert!( 53 | reqwest::get("http://127.0.0.1:8080/@bendn/test") 54 | .await 55 | .unwrap() 56 | .status() 57 | != reqwest::StatusCode::NOT_FOUND 58 | ); 59 | drop(server); 60 | } 61 | -------------------------------------------------------------------------------- /test-server/src/main.rs: -------------------------------------------------------------------------------- 1 | #[tokio::main] 2 | async fn main() { 3 | use std::net::{IpAddr, Ipv4Addr, SocketAddr}; 4 | test_server::TestServer::spawn_blocking(SocketAddr::new( 5 | IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 6 | 8080, 7 | )) 8 | .await; 9 | } 10 | --------------------------------------------------------------------------------