diff options
| -rw-r--r-- | .github/workflows/ci.yml | 2 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | dist/index.html | 539 | ||||
| -rw-r--r-- | mingling_cli/Cargo.lock | 56 | ||||
| -rw-r--r-- | mingling_cli/Cargo.toml | 3 | ||||
| -rw-r--r-- | mingling_cli/src/bin/wrapper.rs | 23 | ||||
| -rw-r--r-- | mingling_cli/src/updater/cmd_update.rs | 287 |
7 files changed, 701 insertions, 216 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90e7d57..4ef1bbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: mling-${{ steps.metadata.outputs.os }}-pkg - path: dist/ + path: dist/mling-* if-no-files-found: error Move-Unreleased-Tag: @@ -112,8 +112,10 @@ tag = "unreleased" features = [] ``` -> [!NOTE] -> To learn more, check out [Writing with Mingling](https://github.com/mingling-rs/mingling/blob/main/GETTING-STARTED.md) +To learn more, check out [Writing with Mingling](https://github.com/mingling-rs/mingling/blob/main/GETTING-STARTED.md) + +> [!Note] +> You can also use the `mling` scaffolding tool to build, check, and manage your project [Download](https://mingling-rs.github.io/mingling/dist) | [About](https://github.com/mingling-rs/mingling/tree/main/mingling_cli) ## Roadmap @@ -160,6 +162,7 @@ See [LICENSE-MIT](LICENSE-MIT) or [LICENSE-APACHE](LICENSE-APACHE) file for deta **To learn more, check out the following links:** +- ⚡ Mingling CLI - [About Mling](https://mingling-rs.github.io/mingling/dist) - 📦 Repo - [Github](https://github.com/mingling-rs/mingling) | [Gitee](https://gitee.com/mingling-rs/mingling) | [Origin](https://catilgrass.cn/mingling.git) - 🚪 Mainpage - [Github](https://mingling-rs.github.io/mingling/) | [crates.io](https://crates.io/crates/mingling) - 💡 Examples - [Github](https://mingling-rs.github.io/mingling/docs/examples.html) diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..6fcdb0b --- /dev/null +++ b/dist/index.html @@ -0,0 +1,539 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Mling CLI — Download</title> + <link + rel="icon" + type="image/png" + href="../docs/res/favicon_small.png" + /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700&family=JetBrains+Mono:wght@400;600&display=swap" + rel="stylesheet" + /> + <style> + *, + *::before, + *::after { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + scroll-behavior: smooth; + } + + body { + font-family: "Noto Serif SC", Georgia, "Times New Roman", serif; + background-color: #1a1410; + color: #e8ddd0; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + } + + a { + color: #d4a84b; + text-decoration: none; + transition: color 0.2s; + } + a:hover { + color: #e8c46a; + } + + .container { + max-width: 1080px; + margin: 0 auto; + padding: 0 1.5rem; + } + + nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 2rem; + background: rgba(26, 20, 16, 0.88); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(212, 168, 75, 0.12); + } + + nav .logo { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + font-size: 1.1rem; + color: #e8ddd0; + } + + nav .logo img { + width: 28px; + height: 28px; + filter: brightness(0) invert(1); + } + + nav .nav-links { + display: flex; + gap: 1.5rem; + align-items: center; + font-size: 0.9rem; + } + + nav .nav-links a { + color: #9a8a7a; + transition: color 0.2s; + } + + nav .nav-links a:hover { + color: #d4a84b; + } + + nav .nav-links .btn-nav { + display: inline-block; + padding: 0.35rem 1rem; + border: 1px solid #d4a84b; + border-radius: 20px; + color: #d4a84b; + font-weight: 600; + font-size: 0.85rem; + transition: + background 0.2s, + color 0.2s; + } + + nav .nav-links .btn-nav:hover { + background: #d4a84b; + color: #1a1410; + } + + .hero { + padding: 5rem 1.5rem 3rem; + text-align: center; + } + + .hero h1 { + font-size: 2.75rem; + font-weight: 700; + color: #e8ddd0; + margin-bottom: 0.75rem; + } + + .hero .subtitle { + font-size: 1.1rem; + color: #9a8a7a; + max-width: 640px; + margin: 0 auto 2.5rem; + } + + .download-row { + display: flex; + gap: 1.25rem; + justify-content: center; + flex-wrap: wrap; + } + + .download-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + min-width: 220px; + padding: 1.75rem 1.5rem; + background: #241c16; + border: 1px solid #3a2e24; + border-radius: 2px; + color: #e8ddd0; + transition: + border-color 0.25s, + transform 0.2s; + } + + .download-card:hover { + border-color: #d4a84b; + color: #e8ddd0; + transform: translateY(-4px); + } + + .download-card .platform { + width: 44px; + height: 44px; + margin-bottom: 0.25rem; + } + + .download-card .platform.invert { + filter: brightness(0) invert(1); + } + + .download-card .os-name { + font-size: 1.05rem; + font-weight: 700; + } + + .download-card .file-name { + font-family: "JetBrains Mono", monospace; + font-size: 0.78rem; + color: #d4a84b; + text-decoration: underline; + transition: color 0.2s; + } + + .download-card .file-name:hover { + color: #e8c46a; + } + + .download-card .checksum { + font-size: 0.78rem; + color: #7a6a5a; + } + + .download-card .checksum a { + color: #7a6a5a; + text-decoration: underline; + } + + .download-card .checksum a:hover { + color: #d4a84b; + } + + .checksum-note { + margin: 1.25rem auto 0; + width: fit-content; + font-size: 0.8rem; + color: #6a5a4a; + text-align: center; + } + + .section { + padding: 3.5rem 1.5rem; + border-top: 1px solid #2a1e14; + } + + .section.alt { + background: #241c16; + } + + .section h2 { + font-size: 1.6rem; + font-weight: 700; + color: #e8ddd0; + margin-bottom: 1rem; + } + + .section h2::before { + content: "✦"; + color: #c43931; + margin-right: 0.5rem; + } + + .section h2 span { + color: #c43931; + } + + .section p { + color: #9a8a7a; + margin-bottom: 1.25rem; + max-width: 720px; + } + + .code-block { + background: #1a1410; + border: 1px solid #3a2e24; + border-radius: 2px; + padding: 1.5rem 1.5rem 1.5rem; + overflow-x: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + } + + .code-block pre { + margin: 0; + padding: 0; + background: transparent; + } + + .code-block code { + font-family: "JetBrains Mono", "Noto Serif SC", monospace; + font-variant-ligatures: contextual; + font-size: 0.85rem; + line-height: 1.65; + color: #c0b0a0; + } + + .code-block code .cm { + color: #6a5a4a; + } + + .cmd-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin-top: 1rem; + } + + .cmd-card { + background: #1a1410; + border: 1px solid #3a2e24; + border-radius: 2px; + padding: 1.25rem 1.25rem; + } + + .cmd-card code { + display: inline-block; + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + color: #d4a84b; + margin-bottom: 0.4rem; + } + + .cmd-card p { + font-size: 0.85rem; + color: #9a8a7a; + margin-bottom: 0; + } + + footer { + padding: 2rem 1.5rem; + text-align: center; + color: #6a5a4a; + font-size: 0.85rem; + border-top: 1px solid #2a1e14; + } + + footer a { + color: #7a6a5a; + } + + footer a:hover { + color: #d4a84b; + } + + @media (max-width: 768px) { + nav { + padding: 0.8rem 1rem; + } + + nav .nav-links { + gap: 0.8rem; + } + + .hero h1 { + font-size: 2rem; + } + + .download-card { + min-width: 100%; + } + } + </style> + </head> + + <body> + <nav> + <a href="../" class="logo"> + <img + src="../docs/res/icon.png" + alt="Mingling icon" + width="28" + height="28" + /> + Mìng Lìng + </a> + <div class="nav-links"> + <a + href="https://github.com/mingling-rs/mingling/tree/main/mingling_cli" + target="_blank" + >GitHub</a + > + <a + href="https://crates.io/crates/mingling-cli" + target="_blank" + class="btn-nav" + >cargo install mingling-cli</a + > + </div> + </nav> + + <section class="hero"> + <h1>Mingling CLI</h1> + <p class="subtitle"> + The command-line companion for scaffolding, managing, and + updating Mingling projects. Install it once, then let + <code>mling update</code> keep it fresh. + </p> + </section> + + <section class="section alt"> + <div class="container"> + <h2>How to use</h2> + <p>Some commands you will use every day:</p> + <div class="cmd-grid"> + <div class="cmd-card"> + <code>mling proj-init 0.4@basic</code> + <p>Initialize a project</p> + </div> + <div class="cmd-card"> + <code>mling install</code> + <p>Install the project to the Mingling package list</p> + </div> + <div class="cmd-card"> + <code>mling pkg-enable your-cli@0.1.0</code> + <p>Enable the specified package</p> + </div> + <div class="cmd-card"> + <code>mling lint</code> + <p>Mingling Linter</p> + </div> + <div class="cmd-card"> + <code>mling -h</code> + <p>Show help messages</p> + </div> + <div class="cmd-card"> + <code>mling update</code> + <p>Update mling to the latest version</p> + </div> + </div> + </div> + </section> + + <section class="section"> + <div class="container"> + <h2>Download</h2> + <p> + Choose the package for your platform and download the latest + build. + </p> + <div class="download-row"> + <div class="download-card"> + <svg + class="platform invert" + viewBox="0 0 128 128" + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M113.823 104.595c-1.795-1.478-3.629-2.921-5.308-4.525-1.87-1.785-3.045-3.944-2.789-6.678.147-1.573-.216-2.926-2.113-3.452.446-1.154.864-1.928 1.033-2.753.188-.92.178-1.887.204-2.834.264-9.96-3.334-18.691-8.663-26.835-2.454-3.748-5.017-7.429-7.633-11.066-4.092-5.688-5.559-12.078-5.633-18.981a47.564 47.564 0 00-1.081-9.475C80.527 11.956 77.291 7.233 71.422 4.7c-4.497-1.942-9.152-2.327-13.901-1.084-6.901 1.805-11.074 6.934-10.996 14.088.074 6.885.417 13.779.922 20.648.288 3.893-.312 7.252-2.895 10.34-2.484 2.969-4.706 6.172-6.858 9.397-1.229 1.844-2.317 3.853-3.077 5.931-2.07 5.663-3.973 11.373-7.276 16.5-1.224 1.9-1.363 4.026-.494 6.199.225.563.363 1.429.089 1.882-2.354 3.907-5.011 7.345-10.066 8.095-3.976.591-4.172 1.314-4.051 5.413.1 3.337.061 6.705-.28 10.021-.363 3.555.008 4.521 3.442 5.373 7.924 1.968 15.913 3.647 23.492 6.854 3.227 1.365 6.465.891 9.064-1.763 2.713-2.771 6.141-3.855 9.844-3.859 6.285-.005 12.572.298 18.86.369 1.702.02 2.679.653 3.364 2.199.84 1.893 2.26 3.284 4.445 3.526 4.193.462 8.013-.16 11.19-3.359 3.918-3.948 8.436-7.066 13.615-9.227 1.482-.619 2.878-1.592 4.103-2.648 2.231-1.922 2.113-3.146-.135-5zM62.426 24.12c.758-2.601 2.537-4.289 5.243-4.801 2.276-.43 4.203.688 5.639 3.246 1.546 2.758 2.054 5.64.734 8.658-1.083 2.474-1.591 2.707-4.123 1.868-.474-.157-.937-.343-1.777-.652.708-.594 1.154-1.035 1.664-1.382 1.134-.772 1.452-1.858 1.346-3.148-.139-1.694-1.471-3.194-2.837-3.175-1.225.017-2.262 1.167-2.4 2.915-.086 1.089.095 2.199.173 3.589-3.446-1.023-4.711-3.525-3.662-7.118zm-12.75-2.251c1.274-1.928 3.197-2.314 5.101-1.024 2.029 1.376 3.547 5.256 2.763 7.576-.285.844-1.127 1.5-1.716 2.241l-.604-.374c-.23-1.253-.276-2.585-.757-3.733-.304-.728-1.257-1.184-1.919-1.762-.622.739-1.693 1.443-1.757 2.228-.088 1.084.477 2.28.969 3.331.311.661 1.001 1.145 1.713 1.916l-1.922 1.51c-3.018-2.7-3.915-8.82-1.871-11.909zM87.34 86.075c-.203 2.604-.5 2.713-3.118 3.098-1.859.272-2.359.756-2.453 2.964a101.744 101.744 0 00-.012 7.753c.061 1.77-.537 3.158-1.755 4.393-6.764 6.856-14.845 10.105-24.512 8.926-4.17-.509-6.896-3.047-9.097-6.639.98-.363 1.705-.607 2.412-.894 3.122-1.27 3.706-3.955 1.213-6.277-1.884-1.757-3.986-3.283-6.007-4.892-1.954-1.555-3.934-3.078-5.891-4.629-1.668-1.323-2.305-3.028-2.345-5.188-.094-5.182.972-10.03 3.138-14.747 1.932-4.209 3.429-8.617 5.239-12.885.935-2.202 1.906-4.455 3.278-6.388 1.319-1.854 2.134-3.669 1.988-5.94-.084-1.276-.016-2.562-.016-3.843l.707-.352c1.141.985 2.302 1.949 3.423 2.959 4.045 3.646 7.892 3.813 12.319.67 1.888-1.341 3.93-2.47 5.927-3.652.497-.294 1.092-.423 1.934-.738 2.151 5.066 4.262 10.033 6.375 15 1.072 2.524 1.932 5.167 3.264 7.547 2.671 4.775 4.092 9.813 4.07 15.272-.012 2.83.137 5.67-.081 8.482z" + /> + </svg> + <span class="os-name">Linux</span> + <a class="file-name" href="./mling-linux.tar.gz" + >mling-linux.tar.gz</a + > + <span class="checksum" + ><a href="./mling-linux.tar.gz.sha256" + >SHA256</a + ></span + > + </div> + <div class="download-card"> + <svg + class="platform invert" + viewBox="0 0 128 128" + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + > + <path + d="M126 1.637l-67 9.834v49.831l67-.534zM1.647 66.709l.003 42.404 50.791 6.983-.04-49.057zm56.82.68l.094 49.465 67.376 9.509.016-58.863zM1.61 19.297l.047 42.383 50.791-.289-.023-49.016z" + /> + </svg> + <span class="os-name">Windows</span> + <a class="file-name" href="./mling-win.tar.gz" + >mling-win.tar.gz</a + > + <span class="checksum" + ><a href="./mling-win.tar.gz.sha256" + >SHA256</a + ></span + > + </div> + <div class="download-card"> + <svg + class="platform invert" + viewBox="0 0 128 128" + xmlns="http://www.w3.org/2000/svg" + aria-hidden="true" + > + <path + d="M97.905 67.885c.174 18.8 16.494 25.057 16.674 25.137-.138.44-2.607 8.916-8.597 17.669-5.178 7.568-10.553 15.108-19.018 15.266-8.318.152-10.993-4.934-20.504-4.934-9.508 0-12.479 4.776-20.354 5.086-8.172.31-14.395-8.185-19.616-15.724C15.822 94.961 7.669 66.8 18.616 47.791c5.438-9.44 15.158-15.417 25.707-15.571 8.024-.153 15.598 5.398 20.503 5.398 4.902 0 14.106-6.676 23.782-5.696 4.051.169 15.421 1.636 22.722 12.324-.587.365-13.566 7.921-13.425 23.639M82.272 21.719c4.338-5.251 7.258-12.563 6.462-19.836-6.254.251-13.816 4.167-18.301 9.416-4.02 4.647-7.54 12.087-6.591 19.216 6.971.54 14.091-3.542 18.43-8.796" + /> + </svg> + <span class="os-name">macOS</span> + <a class="file-name" href="./mling-mac.tar.gz" + >mling-mac.tar.gz</a + > + <span class="checksum" + ><a href="./mling-mac.tar.gz.sha256" + >SHA256</a + ></span + > + </div> + </div> + + <p class="checksum-note"> + Packages are built on every push to + <a + href="https://github.com/mingling-rs/mingling" + target="_blank" + >main</a + > + and published here automatically. + </p> + </div> + </section> + + <section class="section alt"> + <div class="container"> + <h2>Install</h2> + <p> + Extract the package and load the bundled environment script + — it adds <code>bin/</code> to <code>PATH</code> and enables + shell completion. + </p> + <div class="code-block"> + <pre><code><span class="cm"># Linux / macOS (zsh, bash, fish)</span> +tar -xzf mling-linux.tar.gz +source load_mling.sh + +<span class="cm"># Windows (PowerShell)</span> +tar -xzf mling-win.tar.gz +.\load_mling.ps1</code></pre> + </div> + </div> + </section> + + <section class="section"> + <div class="container"> + <h2>Update</h2> + <p> + <code>mling</code> can update itself. It downloads from this + page by default, verifies the sha256 checksum, and applies + the new version on your next invocation. + </p> + <div class="code-block"> + <pre><code><span class="cm"># Update to the latest build</span> +mling update + +<span class="cm"># Point it at a custom mirror if needed</span> +mling cfg update-url https://mingling-rs.github.io/mingling/dist +mling update</code></pre> + </div> + </div> + </section> + + <footer> + <a href="../">Mìng Lìng</a> · + <a href="https://github.com/mingling-rs/mingling" target="_blank" + >GitHub</a + > + · + <a href="../docs/doc.html">Docs</a> + </footer> + </body> +</html> diff --git a/mingling_cli/Cargo.lock b/mingling_cli/Cargo.lock index 40724d2..fd3fb1d 100644 --- a/mingling_cli/Cargo.lock +++ b/mingling_cli/Cargo.lock @@ -26,15 +26,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] name = "arg-picker" version = "0.2.0" dependencies = [ @@ -223,12 +214,6 @@ dependencies = [ ] [[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -274,17 +259,6 @@ dependencies = [ ] [[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] name = "derive_builder" version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -868,7 +842,6 @@ dependencies = [ "tar", "tokio", "toml_edit 0.25.13+spec-1.1.0", - "zip", ] [[package]] @@ -2029,36 +2002,7 @@ dependencies = [ ] [[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror", - "zopfli", -] - -[[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml index 4388fea..009b334 100644 --- a/mingling_cli/Cargo.toml +++ b/mingling_cli/Cargo.toml @@ -69,8 +69,7 @@ just_template = "0.2.1" sha2 = "0.10" # Self-update -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip"] } -zip = { version = "2", default-features = false, features = ["deflate"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip"] } tar = "0.4" flate2 = "1" diff --git a/mingling_cli/src/bin/wrapper.rs b/mingling_cli/src/bin/wrapper.rs index 90bbc53..a47b926 100644 --- a/mingling_cli/src/bin/wrapper.rs +++ b/mingling_cli/src/bin/wrapper.rs @@ -1,10 +1,12 @@ use std::env; use std::ffi::OsString; use std::fs; +use std::io; use std::path::{Component, Path, PathBuf}; use std::process::{self, Command}; use flate2::read::GzDecoder; +use sha2::{Digest, Sha256}; use tar::Archive; fn main() { @@ -83,6 +85,13 @@ fn apply_update_if_present() { match unpack_update(&update_path, ¤t_exe, install_root) { Ok(()) => { + // Record the applied package's checksum so `mling update` can tell + // that this installation is already up to date. + if let Some(checksum_path) = last_update_checksum_path() + && let Ok(checksum) = sha256_file(&update_path) + { + let _ = fs::write(checksum_path, checksum); + } let _ = fs::remove_file(update_path); } Err(e) => eprintln!("mling: failed to apply update: {e}"), @@ -135,3 +144,17 @@ fn sanitize_relative_path(path: &Path) -> PathBuf { } out } + +/// `{data_dir}/mingling/last-update.sha256`, where the wrapper records the +/// checksum of the update it applied. +fn last_update_checksum_path() -> Option<PathBuf> { + dirs::data_dir().map(|data_dir| data_dir.join("mingling").join("last-update.sha256")) +} + +/// The sha256 hex digest of a file. +fn sha256_file(path: &Path) -> io::Result<String> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/mingling_cli/src/updater/cmd_update.rs b/mingling_cli/src/updater/cmd_update.rs index b75f4e9..545361f 100644 --- a/mingling_cli/src/updater/cmd_update.rs +++ b/mingling_cli/src/updater/cmd_update.rs @@ -1,34 +1,40 @@ -use std::{fs, io, path::Path, path::PathBuf}; +use std::{fs, path::Path, path::PathBuf}; use mingling::{ Grouped, LazyRes, RenderResult, Routable, macros::{chain, command, metadata, renderer, routeify}, metadata::Description, }; +use sha2::{Digest, Sha256}; use crate::{Next, config::ResMlingConfig, eprintln_cargo, println_cargo}; -/// Config key holding the GitHub repository that publishes the mling packages. -const CONFIG_KEY_GITHUB: &str = "mingling-github"; +/// Config key holding the base URL that hosts the mling release packages. +const CONFIG_KEY_UPDATE_URL: &str = "update-url"; /// Default update source, used when the config key is unset. -const DEFAULT_GITHUB: &str = "https://github.com/mingling-rs/mingling"; +const DEFAULT_UPDATE_URL: &str = "https://mingling-rs.github.io/mingling/dist"; /// Name of the staged update package inside `{data_dir}/mingling`. const UPDATE_FILE_NAME: &str = "update.tar.gz"; -/// The resolved download task: fetch the newest artifact and stage it. +/// Records the sha256 of the last applied update, written by the wrapper. +const LAST_UPDATE_FILE_NAME: &str = "last-update.sha256"; + +/// The resolved download task: check the remote checksum and stage the package. #[derive(Debug, Default, Grouped)] pub struct StateUpdateDownload { - pub owner: String, - pub repo: String, + pub base_url: String, pub update_path: PathBuf, } -/// The staged update package, ready to be applied by the `mling` wrapper. +/// The latest package is already installed. +#[derive(Debug, Default, Grouped)] +pub struct ResultUpdateUpToDate; + +/// The latest package was downloaded, verified, and staged for the wrapper. #[derive(Debug, Default, Grouped)] -pub struct ResultUpdate { - pub artifact_name: String, +pub struct ResultUpdateStaged { pub update_path: PathBuf, } @@ -37,22 +43,16 @@ pub struct ResultUpdate { pub enum UpdateError { /// The data directory could not be determined. NoDataDirectory, - /// The configured update source is not a GitHub repository URL. - InvalidRepo(String), - /// A network or API request failed. + /// The configured update URL is not a valid `http(s)://` URL. + InvalidUrl(String), + /// A network request failed, or the remote responded with an error. Network(String), - /// No matching artifact was found. - NoArtifact(String), + /// The downloaded package failed its sha256 verification. + ChecksumMismatch(String), /// Writing the staged update package failed. Io(String), } -/// A downloaded artifact with its inner `*.tar.gz` extracted. -struct Artifact { - name: String, - tar_gz: Vec<u8>, -} - #[metadata(EntryUpdate)] pub fn desc_update() -> Description { "Update mling to the latest version".into() @@ -61,45 +61,46 @@ pub fn desc_update() -> Description { #[command(routeify)] pub fn update(config: &mut LazyRes<ResMlingConfig>) -> Next { let config = config.get_ref(); - let source = config.get_or(CONFIG_KEY_GITHUB, DEFAULT_GITHUB); + let source = config.get_or(CONFIG_KEY_UPDATE_URL, DEFAULT_UPDATE_URL); let Some(update_path) = update_package_path() else { return UpdateError::NoDataDirectory.to_chain(); }; - match parse_github_repo(source) { - Some((owner, repo)) => StateUpdateDownload { - owner, - repo, - update_path, - } - .to_chain(), - None => UpdateError::InvalidRepo(source.to_string()).to_chain(), + if !is_http_url(source) { + return UpdateError::InvalidUrl(source.to_string()).to_chain(); + } + StateUpdateDownload { + base_url: source.to_string(), + update_path, } + .to_chain() } -/// Fetch the newest artifact for the current platform and stage it at +/// Check the remote checksum against the installed version; if they differ, +/// download the package, verify its checksum, and stage it at /// `{data_dir}/mingling/update.tar.gz`. #[chain(routeify)] pub async fn handle_state_update_download(state: StateUpdateDownload) -> Next { - match fetch_latest_artifact(&state.owner, &state.repo).await { - Ok(artifact) => { - if let Err(e) = write_update_package(&artifact.tar_gz, &state.update_path) { - return UpdateError::Io(e).to_chain(); - } - ResultUpdate { - artifact_name: artifact.name, - update_path: state.update_path, - } - .to_chain() + match check_and_fetch(&state.base_url, &state.update_path).await { + Ok(FetchOutcome::UpToDate) => ResultUpdateUpToDate.to_chain(), + Ok(FetchOutcome::Staged) => ResultUpdateStaged { + update_path: state.update_path, } + .to_chain(), Err(e) => e.to_chain(), } } #[renderer] -pub fn render_result_update(r: ResultUpdate) -> RenderResult { +pub fn render_result_update_up_to_date(_: ResultUpdateUpToDate) -> RenderResult { + let mut result = RenderResult::new(); + println_cargo!(result, "mling is already up to date"); + result +} + +#[renderer] +pub fn render_result_update_staged(r: ResultUpdateStaged) -> RenderResult { let mut result = RenderResult::new(); - println_cargo!(result, "Downloaded: {}", r.artifact_name); - println_cargo!(result, "Staged: {}", r.update_path.display()); + println_cargo!(result, "Downloaded: {}", r.update_path.display()); println_cargo!(result, "Run `mling` again to apply the update"); result } @@ -111,14 +112,14 @@ pub fn render_error_update(err: UpdateError) -> RenderResult { UpdateError::NoDataDirectory => { eprintln_cargo!(result, "failed to determine the data directory"); } - UpdateError::InvalidRepo(source) => { + UpdateError::InvalidUrl(source) => { eprintln_cargo!( result, - "invalid update source `{}`, expected a GitHub repository URL like `https://github.com/mingling-rs/mingling`", + "invalid update URL `{}`, expected an `http(s)://` URL such as `https://mingling-rs.github.io/mingling/dist`", source ); } - UpdateError::Network(msg) | UpdateError::NoArtifact(msg) | UpdateError::Io(msg) => { + UpdateError::Network(msg) | UpdateError::ChecksumMismatch(msg) | UpdateError::Io(msg) => { eprintln_cargo!(result, "{}", msg); } } @@ -130,27 +131,17 @@ pub fn update_package_path() -> Option<PathBuf> { dirs::data_dir().map(|dir| dir.join("mingling").join(UPDATE_FILE_NAME)) } -/// Extract `owner` / `repo` from a GitHub URL such as -/// `https://github.com/mingling-rs/mingling`. Trailing slashes and `.git` -/// suffixes are tolerated, and a bare `owner/repo` is accepted as well. -fn parse_github_repo(source: &str) -> Option<(String, String)> { - let trimmed = source.trim().trim_end_matches('/'); - if trimmed.is_empty() { - return None; - } - let path = match trimmed.rfind("://") { - Some(idx) => &trimmed[idx + 3..], - None => trimmed, - }; - let mut segments = path.split('/').filter(|s| !s.is_empty()); - // The first segment is the host; take the two path segments after it. - segments.next()?; - let owner = segments.next()?; - let repo = segments.next()?.trim_end_matches(".git"); - Some((owner.to_string(), repo.to_string())) +/// `{data_dir}/mingling/last-update.sha256`, the checksum of the last applied update. +fn last_update_checksum_path() -> Option<PathBuf> { + dirs::data_dir().map(|dir| dir.join("mingling").join(LAST_UPDATE_FILE_NAME)) } -/// The platform suffix used by the CI artifact names (`mling-{os}-...`). +fn is_http_url(source: &str) -> bool { + let source = source.trim(); + source.starts_with("http://") || source.starts_with("https://") +} + +/// The platform suffix used by the package names (`mling-{os}.tar.gz`). fn update_os_name() -> &'static str { if cfg!(windows) { "win" @@ -163,127 +154,113 @@ fn update_os_name() -> &'static str { } } -/// Query the GitHub Actions API, pick the newest non-expired artifact for the -/// current platform, download it, and extract the inner `*.tar.gz`. -async fn fetch_latest_artifact(owner: &str, repo: &str) -> Result<Artifact, UpdateError> { +enum FetchOutcome { + UpToDate, + Staged, +} + +/// Fetch `mling-{os}.tar.gz.sha256`, skip the download when the installed +/// version already matches, then download and verify the package before +/// staging it. +async fn check_and_fetch(base_url: &str, update_path: &Path) -> Result<FetchOutcome, UpdateError> { + let os = update_os_name(); + let base = base_url.trim_end_matches('/'); + let checksum_url = format!("{base}/mling-{os}.tar.gz.sha256"); + let package_url = format!("{base}/mling-{os}.tar.gz"); + let client = reqwest::Client::builder() .user_agent(format!("mling-updater/{}", env!("CARGO_PKG_VERSION"))) .build() .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}")))?; - let list_url = - format!("https://api.github.com/repos/{owner}/{repo}/actions/artifacts?per_page=100"); - let mut request = client - .get(&list_url) - .header("Accept", "application/vnd.github+json"); - if let Ok(token) = std::env::var("GITHUB_TOKEN") - && !token.is_empty() - { - request = request.header("Authorization", format!("Bearer {token}")); - } - - let response = request.send().await.map_err(|e| { - UpdateError::Network(format!("failed to query GitHub Actions artifacts: {e}")) + // 1. Fetch the remote checksum first. + let response = client.get(&checksum_url).send().await.map_err(|e| { + UpdateError::Network(format!( + "failed to fetch checksum from `{checksum_url}`: {e}" + )) })?; if !response.status().is_success() { return Err(UpdateError::Network(format!( - "GitHub Actions API returned {} for `{list_url}`", + "failed to fetch checksum from `{checksum_url}`: HTTP {}", response.status() ))); } - let json: serde_json::Value = response - .json() + let checksum_text = response + .text() .await - .map_err(|e| UpdateError::Network(format!("failed to parse GitHub response: {e}")))?; - - let os = update_os_name(); - let prefix = format!("mling-{os}-"); - let artifact = json - .get("artifacts") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter(|a| { - a.get("expired").and_then(serde_json::Value::as_bool) != Some(true) - && a.get("name") - .and_then(serde_json::Value::as_str) - .is_some_and(|name| name.starts_with(&prefix)) - }) - .max_by_key(|a| { - a.get("created_at") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - }) - .ok_or_else(|| { - UpdateError::NoArtifact(format!("no `{prefix}*` artifact found in {owner}/{repo}")) - })?; + .map_err(|e| UpdateError::Network(format!("failed to read checksum: {e}")))?; + let remote_sha = parse_sha256(&checksum_text).ok_or_else(|| { + UpdateError::Network(format!("invalid checksum file at `{checksum_url}`")) + })?; - let name = artifact - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("mling") - .to_string(); - let download_url = artifact - .get("archive_download_url") - .and_then(serde_json::Value::as_str) - .unwrap_or(&format!( - "https://api.github.com/repos/{owner}/{repo}/actions/artifacts/{}/zip", - artifact - .get("id") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0) - )) - .to_string(); + // 2. Skip the download when the installed version already matches. + if let Some(local_sha) = read_last_update_checksum() + && local_sha == remote_sha + { + return Ok(FetchOutcome::UpToDate); + } + // 3. Download the package. let response = - client.get(&download_url).send().await.map_err(|e| { - UpdateError::Network(format!("failed to download artifact `{name}`: {e}")) + client.get(&package_url).send().await.map_err(|e| { + UpdateError::Network(format!("failed to download `{package_url}`: {e}")) })?; if !response.status().is_success() { return Err(UpdateError::Network(format!( - "failed to download artifact `{name}`: HTTP {}", + "failed to download `{package_url}`: HTTP {}", response.status() ))); } - let zip_bytes = response + let bytes = response .bytes() .await - .map_err(|e| UpdateError::Network(format!("failed to read artifact `{name}`: {e}")))?; + .map_err(|e| UpdateError::Network(format!("failed to read package body: {e}")))?; - let tar_gz = extract_tar_gz_from_zip(&zip_bytes) - .map_err(|e| UpdateError::Network(format!("invalid artifact `{name}`: {e}")))?; - Ok(Artifact { name, tar_gz }) + // 4. Verify the package before staging it. + let actual_sha = sha256_hex(&bytes); + if actual_sha != remote_sha { + return Err(UpdateError::ChecksumMismatch(format!( + "checksum mismatch for `{package_url}`: expected {remote_sha}, got {actual_sha}" + ))); + } + + // 5. Stage it for the wrapper. + write_update_package(&bytes, update_path)?; + Ok(FetchOutcome::Staged) } -/// The GitHub artifact is a zip containing the `mling-{os}-{sha}-{date}.tar.gz` -/// built by CI; extract that inner file. -fn extract_tar_gz_from_zip(zip_bytes: &[u8]) -> Result<Vec<u8>, String> { - let reader = io::Cursor::new(zip_bytes); - let mut archive = zip::ZipArchive::new(reader).map_err(|e| e.to_string())?; - for index in 0..archive.len() { - let mut file = archive.by_index(index).map_err(|e| e.to_string())?; - let file_name = file.name().to_string(); - if file_name.ends_with(".tar.gz") { - let mut tar_gz = Vec::with_capacity(file.size() as usize); - io::copy(&mut file, &mut tar_gz).map_err(|e| e.to_string())?; - return Ok(tar_gz); - } - } - Err("artifact contains no `*.tar.gz` file".to_string()) +/// Parse the sha256 hex digest from a `sha256sum`-style line (`<hash> <file>`). +fn parse_sha256(line: &str) -> Option<String> { + let hash = line.split_whitespace().next()?; + (hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())).then(|| hash.to_string()) +} + +/// The checksum of the last update applied by the wrapper, if recorded. +fn read_last_update_checksum() -> Option<String> { + let path = last_update_checksum_path()?; + let content = fs::read_to_string(path).ok()?; + let sha = content.trim(); + (!sha.is_empty()).then(|| sha.to_string()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) } /// Stage the update package at the wrapper's well-known location. The bytes are /// written to a temporary file first so a failed download never corrupts a /// previously staged update. -fn write_update_package(tar_gz: &[u8], update_path: &Path) -> Result<(), String> { - let parent = update_path - .parent() - .ok_or_else(|| format!("no parent directory for {}", update_path.display()))?; - fs::create_dir_all(parent).map_err(|e| e.to_string())?; +fn write_update_package(package: &[u8], update_path: &Path) -> Result<(), UpdateError> { + let parent = update_path.parent().ok_or_else(|| { + UpdateError::Io(format!("no parent directory for {}", update_path.display())) + })?; + fs::create_dir_all(parent).map_err(|e| UpdateError::Io(e.to_string()))?; let tmp_path = parent.join("update.tar.gz.tmp"); - fs::write(&tmp_path, tar_gz).map_err(|e| e.to_string())?; + fs::write(&tmp_path, package).map_err(|e| UpdateError::Io(e.to_string()))?; if update_path.exists() { - fs::remove_file(update_path).map_err(|e| e.to_string())?; + fs::remove_file(update_path).map_err(|e| UpdateError::Io(e.to_string()))?; } - fs::rename(&tmp_path, update_path).map_err(|e| e.to_string()) + fs::rename(&tmp_path, update_path).map_err(|e| UpdateError::Io(e.to_string())) } |
