diff --git a/Cargo.toml b/Cargo.toml index bea49e3..5c0d241 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ include = [ "/src/**/*.jsonc", "/README.md", "/LICENSE", + "/install.sh", "/crabcode-logo.txt", "/favicon.png", "/mascot.txt", diff --git a/README.md b/README.md index bcd4c45..d31b5d9 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ cargo install crabcode # or cargo (build from source) curl -sSL https://raw.githubusercontent.com/Blankeos/crabcode/main/install.sh | sh # or linux/macos (via curl) ``` +The shell installer downloads a prebuilt release, verifies its SHA256 checksum, and installs to `~/.local/bin`. Set `CRABCODE_INSTALL_DIR` for a different destination, or pass a version with `sh -s -- 0.0.14`. Installation and upgrades use compact version labels and amber progress in a terminal; redirected output stays plain. + ### Upgrade Detects how you installed (brew / npm / bun / cargo / install.sh) and upgrades in place: diff --git a/install.sh b/install.sh index ecf1382..9d55711 100755 --- a/install.sh +++ b/install.sh @@ -1,64 +1,248 @@ -#!/bin/bash +#!/bin/sh +# Prebuilt release installer. Also works with: curl .../install.sh | sh -s -- [version] +set -eu -set -e - -VERSION="latest" -REPO="Blankeos/crabcode" -INSTALL_DIR="$HOME/.local/bin" -BINARY_NAME="crabcode" - -echo "🦀 Installing crabcode..." - -# Check if cargo is available -if command -v cargo &> /dev/null; then - echo "📦 Installing via cargo..." - cargo install crabcode - echo "✓ crabcode installed successfully via cargo" - echo "" - echo "Run: crabcode" - exit 0 -fi - -# Fall back to downloading pre-built binary -echo "⬇️ Downloading pre-built binary..." - -# Determine platform -OS="$(uname -s)" -ARCH="$(uname -m)" - -case "$OS" in - Linux*) OS="linux";; - Darwin*) OS="macos";; - *) echo "❌ Unsupported OS: $OS"; exit 1;; +say() { printf '%s\n' "$*" >&2; } +die() { say "Error: $*"; exit 1; } +valid_version() { + case "$1" in ''|*[!0-9A-Za-z.+-]*) return 1 ;; esac + printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?(\+[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$' +} +[ "$#" -le 1 ] || die 'Usage: install.sh [version|latest]' +version=${1-latest} +case "$version" in + latest) ;; + *) version=${version#v} + # Keep tags out of URLs until validated (no options, paths, or whitespace). + valid_version "$version" || die 'Invalid version; expected a semantic version or latest' ;; esac +for tool in curl tar awk od mktemp; do + command -v "$tool" >/dev/null 2>&1 || die "Required tool not found: $tool" +done +if command -v sha256sum >/dev/null 2>&1; then + hash_tool=sha256sum +elif command -v shasum >/dev/null 2>&1; then + hash_tool=shasum +else + die 'SHA256 verification requires sha256sum or shasum' +fi +os=$(uname -s) +arch=$(uname -m) +case "$arch" in arm64|aarch64) arch=aarch64 ;; x86_64) ;; *) die "Unsupported architecture: $arch" ;; esac +case "$os" in Darwin) target=$arch-apple-darwin ;; Linux) target=$arch-unknown-linux-gnu ;; *) die "Unsupported OS: $os" ;; esac +: "${HOME:?HOME must be set}" +install_dir=${CRABCODE_INSTALL_DIR:-$HOME/.local/bin} +case "$install_dir" in /*) ;; *) install_dir=$PWD/$install_dir ;; esac +work=$(mktemp -d "${TMPDIR:-/tmp}/crabcode-install.XXXXXXXX") +pending= +parser_pid= +probe_pid= +watchdog_pid= +cleanup() { + [ -z "$probe_pid" ] || kill -KILL "$probe_pid" 2>/dev/null || : + [ -z "$watchdog_pid" ] || kill "$watchdog_pid" 2>/dev/null || : + [ -z "$parser_pid" ] || kill "$parser_pid" 2>/dev/null || : + [ -z "$probe_pid" ] || wait "$probe_pid" 2>/dev/null || : + [ -z "$watchdog_pid" ] || wait "$watchdog_pid" 2>/dev/null || : + [ -z "$parser_pid" ] || wait "$parser_pid" 2>/dev/null || : + [ -z "$pending" ] || rm -f "$pending" + rm -rf "$work" +} +trap cleanup 0 +trap 'exit 130' INT +trap 'exit 143' TERM HUP -case "$ARCH" in - x86_64) ARCH="x86_64";; - aarch64) ARCH="aarch64";; - *) echo "❌ Unsupported architecture: $ARCH"; exit 1;; -esac +repo=https://github.com/blankeos/crabcode +# Follow GitHub's HTTPS latest redirect; no JSON parser or API rate-limit dependency. +if [ "$version" = latest ]; then + if ! curl --proto '=https' --proto-redir '=https' --fail --location --silent --show-error \ + --connect-timeout 10 --max-time 20 --output "$work/latest" --write-out '%{url_effective}' "$repo/releases/latest" >"$work/url"; then + die 'Could not resolve latest release' + fi + tag=$(cat "$work/url") + tag=${tag##*/} + version=${tag#v} + valid_version "$version" || die 'Latest release returned an invalid version' +fi +current='not installed' +installed= +if [ -x "$install_dir/crabcode" ]; then + installed=$install_dir/crabcode +elif command -v crabcode >/dev/null 2>&1; then + installed=$(command -v crabcode) +fi +if [ -n "$installed" ]; then + current=unknown + # No timeout utility on stock macOS. Probe only our child, with no access + # to the installer's stdin (which may itself be a piped shell script). + "$installed" --version "$work/version" 2>/dev/null & + probe_pid=$! + ( + sleep 2 & timer=$! + trap 'kill "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; exit 0' TERM INT HUP + wait "$timer" + kill -KILL "$probe_pid" 2>/dev/null || : + ) & + watchdog_pid=$! + if wait "$probe_pid" 2>/dev/null; then + # Accept one compact line only; never echo arbitrary terminal controls. + candidate=$(LC_ALL=C awk 'NR==1 && length($0)<=128 { line=$0 } END { if(NR==1) print line }' "$work/version") + candidate=${candidate#crabcode } + if valid_version "$candidate"; then current=$candidate; fi + fi + probe_pid= + kill "$watchdog_pid" 2>/dev/null || : + wait "$watchdog_pid" 2>/dev/null || : + watchdog_pid= +fi +say "Installed version: $current" +say '' +say "Installing crabcode version: $version" -# Create install directory -mkdir -p "$INSTALL_DIR" +ui=0 +color=0 +width=30 +if [ -t 2 ] && [ "${TERM:-dumb}" != dumb ]; then + ui=1 + [ -n "${NO_COLOR+x}" ] || color=1 + columns=${COLUMNS-} + if [ -z "${COLUMNS+x}" ]; then + size=$(stty size <&2 2>/dev/null || :) + columns=${size##* } + fi + case "$columns" in ''|*[!0-9]*) columns=80 ;; esac + [ "$columns" -gt 0 ] || columns=80 + available=$((columns - 1)) + width=$((available - 6)) + [ "$width" -le 50 ] || width=50 + [ "$width" -ge 0 ] || width=0 +fi -# Download binary -BINARY_URL="https://github.com/${REPO}/releases/download/${VERSION}/crabcode-${OS}-${ARCH}" +# curl runs directly, not in a pipeline: its exit status and HTTP status survive. +# Parse its real, CR-delimited progress updates concurrently. Unknown-length +# transfers stay textual rather than inventing a percentage. +download() { + url=$1 + destination=$2 + : >"$work/errors" + phase=${3-archive} + max_time=300 + if [ "$phase" = checksum ]; then + say 'Verifying download...' + max_time=20 + else + say 'Downloading...' + fi + if [ "$ui" = 1 ] && [ "$phase" = archive ]; then + rm -f "$work/progress" + mkfifo "$work/progress" + LC_ALL=C awk -v width="$width" -v available="$available" -v color="$color" -v errors="$work/errors" ' + BEGIN { RS="\r|\n"; shown=0 } + /[0-9]+(\.[0-9]+)?%/ { + match($0, /[0-9]+(\.[0-9]+)?%/) + percent=substr($0, RSTART, RLENGTH); value=percent+0 + if (value<0 || value>100) next + filled=int(width*value/100); bar=""; rest="" + for(i=0;i0) printf "%s%s %s", bar, rest, percent + else printf "%s", substr(percent, 1, available) + if(color) printf "\033[0m" + fflush(); shown=1; next + } + NF && $0 !~ /^[ #=-]+$/ { print $0 >> errors } + END { if(shown) printf "\n" } + ' <"$work/progress" >&2 & + parser_pid=$! + if curl --proto '=https' --proto-redir '=https' --fail --location --show-error --progress-bar \ + --connect-timeout 10 --max-time "$max_time" --output "$destination" --write-out '%{http_code}' "$url" >"$work/status" 2>"$work/progress"; then + result=0 + else result=$?; fi + wait "$parser_pid" || : + parser_pid= + else + if curl --proto '=https' --proto-redir '=https' --fail --location --silent --show-error \ + --connect-timeout 10 --max-time "$max_time" --output "$destination" --write-out '%{http_code}' "$url" >"$work/status" 2>"$work/errors"; then + result=0 + else result=$?; fi + fi + http_status=$(cat "$work/status") + [ "$result" = 0 ] || cat "$work/errors" >&2 + return "$result" +} -if curl -L "$BINARY_URL" -o "$INSTALL_DIR/$BINARY_NAME"; then - chmod +x "$INSTALL_DIR/$BINARY_NAME" - echo "✓ crabcode installed successfully to $INSTALL_DIR/$BINARY_NAME" +archive=crabcode-$target.tar.gz +base=$repo/releases/download/v$version +if ! download "$base/$archive" "$work/archive"; then + # Older releases used xz. Only missing assets justify switching formats. + [ "$http_status" = 404 ] || die 'Archive download failed; existing installation was not changed' + archive=crabcode-$target.tar.xz + download "$base/$archive" "$work/archive" || die 'Legacy archive download failed' +fi +download "$base/$archive.sha256" "$work/checksum" checksum || die 'Required checksum download failed' +expected=$(awk 'NR==1 { print $1 }' "$work/checksum") +[ "${#expected}" = 64 ] || die 'Invalid SHA256 checksum' +case "$expected" in *[!0-9a-fA-F]*) die 'Invalid SHA256 checksum' ;; esac +if [ "$hash_tool" = sha256sum ]; then + actual=$(sha256sum "$work/archive" | awk '{print $1}') else - echo "❌ Failed to download binary. Please install via cargo: cargo install crabcode" - exit 1 + actual=$(shasum -a 256 "$work/archive" | awk '{print $1}') fi +expected=$(printf '%s' "$expected" | tr 'A-F' 'a-f') +[ "$actual" = "$expected" ] || die 'SHA256 checksum mismatch; existing installation was not changed' -# Add to PATH if not already there -if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - echo "" - echo "⚠️ Add $INSTALL_DIR to your PATH:" - echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" - echo " Add this to your ~/.bashrc or ~/.zshrc" -fi +# Never unpack untrusted paths into HOME. Validate the inventory, then stream +# exactly one regular binary into staging (symlinks and hardlinks are refused). +tar -tf "$work/archive" >"$work/members" || die 'Invalid release archive' +member=$(awk ' + /^\// || /^-/ || /(^|\/)\.\.(\/|$)/ { bad=1 } + /(^|\/)crabcode$/ { candidate=$0; count++ } + END { if(bad || count!=1) exit 1; print candidate } +' "$work/members") || die 'Unsafe archive or missing/duplicate crabcode binary' +tar -tvf "$work/archive" "$member" >"$work/type" || die 'Could not inspect binary' +case "$(head -c 1 "$work/type")" in -) ;; *) die 'Archive binary is not a regular file' ;; esac +tar -xOf "$work/archive" "$member" >"$work/crabcode" || die 'Could not extract binary' +[ -s "$work/crabcode" ] || die 'Archive binary is empty' +chmod 755 "$work/crabcode" -echo "" -echo "Run: $BINARY_NAME" +# JSON-escape arbitrary prefix bytes, including quotes, backslashes and control +# characters, without relying on Python, Node, or unsafe shell interpolation. +json_string() { + printf '%s' "$1" | od -An -v -tu1 | LC_ALL=C awk ' + BEGIN { printf "\"" } + { for(i=1;i<=NF;i++) { + n=$i+0 + if(n==34 || n==92) printf "\\%c", n + else if(n<32) printf "\\u%04x", n + else printf "%c", n + } } + END { printf "\"" } + ' +} +mkdir -p "$install_dir" +install_dir=$(cd "$install_dir" && pwd -P) +receipt_dir=${XDG_CONFIG_HOME:-$HOME/.config}/crabcode +mkdir -p "$receipt_dir" +{ + printf '{"binaries":["crabcode"],"binary_aliases":{},"install_prefix":' + json_string "$install_dir" + printf ',"install_layout":"flat","version":' + json_string "$version" + printf '}\n' +} >"$work/receipt" +# Copy beside the destination and rename atomically, even across filesystems. +pending=$(mktemp "$install_dir/.crabcode.XXXXXXXX") +cp "$work/crabcode" "$pending" +chmod 755 "$pending" +mv -f "$pending" "$install_dir/crabcode" +pending= +pending=$(mktemp "$receipt_dir/.receipt.XXXXXXXX") +cp "$work/receipt" "$pending" +mv -f "$pending" "$receipt_dir/crabcode-receipt.json" +pending= +say "Installed crabcode $version to $install_dir/crabcode" +case ":$PATH:" in *":$install_dir:"*) ;; *) say "Add $install_dir to your PATH to run crabcode." ;; esac diff --git a/justfile b/justfile index c5ab72a..afdb461 100644 --- a/justfile +++ b/justfile @@ -4,9 +4,14 @@ default: check-aisdk-boundary: bash scripts/check-aisdk-boundary.sh -[doc('Check registry publishing workflow and npm archive compatibility')] +[doc('Check release publishing and offline shell/npm installer behavior')] test-release: - bun test scripts/release-workflow.test.ts npm/install.test.js + bun test --timeout 30000 scripts/release-workflow.test.ts scripts/install-ui.test.ts npm/install.test.js + +[doc('Check the compiled upgrade CLI, install routing, and terminal rendering offline')] +test-upgrade-ui: + cargo build --bin crabcode + bun test --timeout 30000 scripts/upgrade-ui.test.ts dev: cargo r diff --git a/npm/install.js b/npm/install.js index cee81ce..329e354 100755 --- a/npm/install.js +++ b/npm/install.js @@ -1,14 +1,119 @@ #!/usr/bin/env node -const { execSync } = require("child_process"); +const { execSync, execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); const https = require("https"); +const { pipeline, Transform } = require("stream"); +const { promisify } = require("util"); +const pipe = promisify(pipeline); // Version should match your Rust crate version. const VERSION = require("./package.json").version; const BINARY_NAME = "crabcode"; +class DownloadProgress { + constructor(stderr = process.stderr, env = process.env) { + this.stderr = stderr; + this.redraw = Boolean(stderr.isTTY && env.TERM !== "dumb"); + this.color = + this.redraw && !Object.prototype.hasOwnProperty.call(env, "NO_COLOR"); + this.total = null; + this.bytes = 0; + this.closed = false; + } + + start(total) { + const length = Number(total); + this.total = Number.isSafeInteger(length) && length > 0 ? length : null; + if (!this.redraw || !this.total) this.stderr.write("Downloading...\n"); + else this.render(false); + } + + advance(bytes) { + this.bytes += bytes; + if (!this.closed && this.redraw && this.total) this.render(false); + } + + render(success) { + // Receipt of the final chunk is not success: the destination must finish too. + const percent = success + ? 100 + : Math.min(99, Math.floor((this.bytes / this.total) * 100)); + const columns = Number.isFinite(this.stderr.columns) + ? Math.max(1, Math.floor(this.stderr.columns)) + : 80; + const available = Math.max(0, columns - 1); // Avoid terminal auto-wrap. + const suffix = ` ${String(percent).padStart(3)}%`; + const width = Math.min(50, Math.max(0, available - suffix.length)); + const filled = Math.floor((width * percent) / 100); + const blocks = "█".repeat(filled); + const bar = + (this.color && blocks ? `\x1b[33m${blocks}\x1b[0m` : blocks) + + ".".repeat(width - filled); + const text = width + ? bar + suffix + : available >= 4 + ? `${percent}%` + : ".".repeat(available); + this.stderr.write(`\r${text}`); + } + + finish(success) { + if (this.closed) return; + if (this.redraw && this.total) { + if (success) this.render(true); + this.stderr.write("\n"); + } + this.closed = true; + } +} + +function installedVersion( + binaryPath, + run = execFileSync, + exists = fs.existsSync, +) { + if (!exists(binaryPath)) return "not installed"; + try { + const output = run(binaryPath, ["--version"], { + timeout: 2000, + maxBuffer: 4096, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const match = /^crabcode\s+v?(\S+)$/i.exec(output); + // Only expose a compact version token, never arbitrary terminal control text. + const version = match ? match[1] : output; + return /^[0-9][0-9A-Za-z.+-]*$/.test(version) ? version : "unknown"; + } catch (_) { + return "unknown"; + } +} + +async function transferDownload(response, file, progress) { + progress.start(response.headers && response.headers["content-length"]); + const counter = new Transform({ + transform(chunk, encoding, callback) { + progress.advance(chunk.length); + callback(null, chunk); + }, + }); + try { + await pipe(response, counter, file); + if ( + response.complete === false || + (progress.total && progress.bytes !== progress.total) + ) { + throw new Error("Incomplete download"); + } + progress.finish(true); + } catch (error) { + progress.finish(false); + throw error; + } +} + function getPlatformInfo() { const platform = process.platform; const arch = process.arch; @@ -52,20 +157,30 @@ function getPlatformInfo() { function fetchResponse(url, redirects = 0) { return new Promise((resolve, reject) => { if (redirects > 5) return reject(new Error("Too many download redirects")); - https.get(url, (res) => { - if ([301, 302, 303, 307, 308].includes(res.statusCode)) { - res.resume(); - if (!res.headers.location) return reject(new Error("Missing redirect location")); - resolve(fetchResponse(new URL(res.headers.location, url).href, redirects + 1)); - } else if (res.statusCode === 200) { - resolve(res); - } else { - res.resume(); - const error = new Error(`Failed to download: ${res.statusCode} ${res.statusMessage}`); - error.statusCode = res.statusCode; - reject(error); - } - }).on("error", reject); + https + .get(url, (res) => { + if ([301, 302, 303, 307, 308].includes(res.statusCode)) { + res.resume(); + if (!res.headers.location) + return reject(new Error("Missing redirect location")); + resolve( + fetchResponse( + new URL(res.headers.location, url).href, + redirects + 1, + ), + ); + } else if (res.statusCode === 200) { + resolve(res); + } else { + res.resume(); + const error = new Error( + `Failed to download: ${res.statusCode} ${res.statusMessage}`, + ); + error.statusCode = res.statusCode; + reject(error); + } + }) + .on("error", reject); }); } @@ -87,22 +202,39 @@ async function downloadArchive(info, dest, download = downloadFile) { } } -async function downloadFile(url, dest) { - console.error(`Downloading ${url}...`); - - const response = await fetchResponse(url); - const file = fs.createWriteStream(dest); - response.pipe(file); - return new Promise((resolve, reject) => { - file.on("finish", () => { - file.close(); - resolve(); - }); - file.on("error", (err) => { - fs.unlink(dest, () => {}); - reject(err); - }); - }); +async function downloadFile( + url, + dest, + { + fetch = fetchResponse, + createFile = fs.createWriteStream, + progress = new DownloadProgress(), + remove = fs.unlinkSync, + } = {}, +) { + let created = false; + try { + const response = await fetch(url); + let file; + try { + file = createFile(dest); + created = true; + } catch (error) { + response.destroy(); + throw error; + } + await transferDownload(response, file, progress); + } catch (error) { + progress.finish(false); + if (created) { + try { + remove(dest); + } catch (_) { + /* Keep the original download error. */ + } + } + throw error; + } } function extractArchive(archivePath, extractDir, platformInfo) { @@ -133,16 +265,24 @@ async function install({ exitOnComplete = false } = {}) { const archivePath = path.join(__dirname, platformInfo.filename); const binaryPath = path.join(binDir, platformInfo.binaryName); + console.error(`Installed version: ${installedVersion(binaryPath)}`); + console.error(`\nInstalling crabcode version: ${VERSION}`); + if (!fs.existsSync(binDir)) fs.mkdirSync(binDir, { recursive: true }); platformInfo = await downloadArchive(platformInfo, archivePath); extractArchive(archivePath, __dirname, platformInfo); + console.error("Installing binary..."); const extractedBinaryPath = path.join(__dirname, platformInfo.binaryName); if (fs.existsSync(extractedBinaryPath)) { fs.renameSync(extractedBinaryPath, binaryPath); } else { - const subdirPath = path.join(__dirname, `${BINARY_NAME}-${platformInfo.target}`, platformInfo.binaryName); + const subdirPath = path.join( + __dirname, + `${BINARY_NAME}-${platformInfo.target}`, + platformInfo.binaryName, + ); if (fs.existsSync(subdirPath)) { fs.renameSync(subdirPath, binaryPath); fs.rmSync(path.dirname(subdirPath), { recursive: true, force: true }); @@ -155,8 +295,8 @@ async function install({ exitOnComplete = false } = {}) { fs.chmodSync(binaryPath, 0o755); } - fs.unlinkSync(archivePath); - console.error(`crabcode v${VERSION} installed successfully!`); + fs.unlinkSync(archivePath); + console.error(`crabcode v${VERSION} installed successfully!`); if (exitOnComplete) { process.exit(0); @@ -181,4 +321,12 @@ if (require.main === module) { install({ exitOnComplete: true }); } -module.exports = { getPlatformInfo, downloadArchive, install }; +module.exports = { + getPlatformInfo, + downloadArchive, + downloadFile, + transferDownload, + DownloadProgress, + installedVersion, + install, +}; diff --git a/npm/install.test.js b/npm/install.test.js index ff7ae6a..b338ae1 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -1,36 +1,310 @@ -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const { getPlatformInfo, downloadArchive } = require('./install'); +const { test } = require("node:test"); +const assert = require("node:assert/strict"); +const { getPlatformInfo, downloadArchive } = require("./install"); const info = { - extension: '.tar.gz', filename: 'crabcode-test.tar.gz', - url: 'https://example.com/crabcode-test.tar.gz', + extension: ".tar.gz", + filename: "crabcode-test.tar.gz", + url: "https://example.com/crabcode-test.tar.gz", }; -test('current platform uses registry-compatible archives', () => { - assert.equal(getPlatformInfo().extension, process.platform === 'win32' ? '.zip' : '.tar.gz'); +test("current platform uses registry-compatible archives", () => { + assert.equal( + getPlatformInfo().extension, + process.platform === "win32" ? ".zip" : ".tar.gz", + ); }); -test('gzip download succeeds without fallback', async () => { +test("gzip download succeeds without fallback", async () => { const urls = []; - assert.equal(await downloadArchive(info, '/unused', async (url) => urls.push(url)), info); + assert.equal( + await downloadArchive(info, "/unused", async (url) => urls.push(url)), + info, + ); assert.deepEqual(urls, [info.url]); }); -test('older xz assets are retried only after gzip 404', async () => { +test("older xz assets are retried only after gzip 404", async () => { const urls = []; - const result = await downloadArchive(info, '/unused', async (url) => { + const result = await downloadArchive(info, "/unused", async (url) => { urls.push(url); - if (urls.length === 1) throw Object.assign(new Error('missing'), { statusCode: 404 }); + if (urls.length === 1) + throw Object.assign(new Error("missing"), { statusCode: 404 }); }); - assert.equal(result.extension, '.tar.xz'); - assert.deepEqual(urls, [info.url, 'https://example.com/crabcode-test.tar.xz']); + assert.equal(result.extension, ".tar.xz"); + assert.deepEqual(urls, [ + info.url, + "https://example.com/crabcode-test.tar.xz", + ]); }); -test('network failures and Windows zip failures do not fallback', async () => { - for (const [asset, error] of [[info, new Error('network')], [{ ...info, extension: '.zip' }, Object.assign(new Error('missing'), { statusCode: 404 })]]) { +test("network failures and Windows zip failures do not fallback", async () => { + for (const [asset, error] of [ + [info, new Error("network")], + [ + { ...info, extension: ".zip" }, + Object.assign(new Error("missing"), { statusCode: 404 }), + ], + ]) { let attempts = 0; - await assert.rejects(downloadArchive(asset, '/unused', async () => { attempts++; throw error; }), error); + await assert.rejects( + downloadArchive(asset, "/unused", async () => { + attempts++; + throw error; + }), + error, + ); assert.equal(attempts, 1); } }); + +const { Readable, Writable } = require("node:stream"); +const { + DownloadProgress, + transferDownload, + downloadFile, + installedVersion, +} = require("./install"); +function capture({ tty = true, columns = 80, env = { TERM: "xterm" } } = {}) { + let text = ""; + const stderr = { + isTTY: tty, + columns, + write(value) { + text += value; + }, + }; + return { progress: new DownloadProgress(stderr, env), text: () => text }; +} +function response(chunks, total) { + const stream = Readable.from(chunks); + stream.headers = + total === undefined ? {} : { "content-length": String(total) }; + return stream; +} +function sink() { + return new Writable({ + write(chunk, encoding, done) { + done(); + }, + }); +} +const strip = (text) => text.replace(/\x1b\[[0-9;]*m/g, ""); + +test("known progress renders 0 and 17 percent, withholds 100 until successful finish", () => { + const ui = capture(); + ui.progress.start(100); + assert.match(ui.text(), / 0%/); + ui.progress.advance(17); + assert.match(ui.text(), / 17%/); + assert.match(ui.text(), /\x1b\[33m█/); + ui.progress.advance(83); + assert.doesNotMatch(ui.text(), /100%/); + ui.progress.finish(true); + assert.match(ui.text(), /100%\n$/); + assert.equal( + strip(ui.text()).split("\r").pop().trim().split(" ")[0].length, + 50, + ); +}); + +test("unknown, invalid and zero totals remain static downloading text", async () => { + for (const total of [undefined, 0, -1, "bad", Infinity]) { + const ui = capture(); + await transferDownload( + response([Buffer.from("abc")], total), + sink(), + ui.progress, + ); + assert.equal(ui.text(), "Downloading...\n"); + } +}); + +test("bar fits narrow terminals and is capped at 50 cells", () => { + for (const columns of [1, 2, 4, 5, 6, 7, 12, 20, 55, 80, 200]) { + const ui = capture({ columns }); + ui.progress.start(100); + ui.progress.advance(17); + ui.progress.finish(true); + for (const line of strip(ui.text()).split("\r").filter(Boolean)) { + const visible = line.replace(/\n$/, ""); + assert.ok(visible.length < columns, `${columns}: ${visible}`); + assert.ok((visible.match(/[█.]/g) || []).length <= 50); + } + } +}); + +test("redirected stderr and dumb terminals have no ANSI or carriage returns", async () => { + for (const options of [{ tty: false }, { env: { TERM: "dumb" } }]) { + const ui = capture(options); + await transferDownload( + response([Buffer.alloc(17), Buffer.alloc(83)], 100), + sink(), + ui.progress, + ); + assert.equal(ui.text(), "Downloading...\n"); + assert.doesNotMatch(ui.text(), /[\x1b\r]/); + } +}); + +test("NO_COLOR disables amber but retains interactive redraw", () => { + const ui = capture({ env: { TERM: "xterm", NO_COLOR: "" } }); + ui.progress.start(100); + ui.progress.advance(17); + ui.progress.finish(true); + assert.doesNotMatch(ui.text(), /\x1b/); + assert.match(ui.text(), /\r/); + assert.match(ui.text(), /100%\n$/); +}); + +test("successful pipeline waits for the destination before displaying 100", async () => { + const ui = capture(); + let release; + const file = new Writable({ + write(chunk, encoding, done) { + done(); + }, + final(done) { + release = done; + }, + }); + const pending = transferDownload( + response([Buffer.alloc(100)], 100), + file, + ui.progress, + ); + while (!release) await new Promise((resolve) => setImmediate(resolve)); + assert.doesNotMatch(ui.text(), /100%/); + release(); + await pending; + assert.match(ui.text(), /100%\n$/); +}); + +test("truncation, abort, readable and writable errors close progress and clean partial files", async () => { + for (const mode of [ + "truncated", + "abort", + "read-error", + "write-error", + "incomplete", + ]) { + const ui = capture(); + let stream = response([Buffer.alloc(17)], 100); + if (mode === "abort" || mode === "read-error") { + stream = new Readable({ + read() { + this.push(Buffer.alloc(17)); + if (mode === "abort") this.destroy(); + else this.destroy(new Error("read failed")); + }, + }); + stream.headers = { "content-length": "100" }; + } + if (mode === "incomplete") { + stream = response([Buffer.alloc(100)], 100); + stream.complete = false; + } + const file = + mode === "write-error" + ? new Writable({ + write(chunk, encoding, done) { + done(new Error("disk failed")); + }, + }) + : sink(); + let removed = false; + await assert.rejects( + downloadFile("unused", "/partial", { + fetch: async () => stream, + createFile: () => file, + progress: ui.progress, + remove: (dest) => { + assert.equal(dest, "/partial"); + removed = true; + }, + }), + ); + assert.equal(removed, true, mode); + assert.equal(file.destroyed, true, mode); + assert.match(ui.text(), /\n$/); + assert.doesNotMatch(ui.text(), /100%/); + const before = ui.text(); + ui.progress.finish(false); + assert.equal(ui.text(), before); + } +}); + +test("download success preserves archive; fetch errors do not remove an existing file", async () => { + for (const fail of [false, true]) { + const ui = capture(); + let removed = false; + const pending = downloadFile("unused", "/archive", { + fetch: async () => { + if (fail) throw new Error("offline"); + return response([Buffer.alloc(100)], 100); + }, + createFile: () => sink(), + progress: ui.progress, + remove: () => { + removed = true; + }, + }); + if (fail) await assert.rejects(pending, /offline/); + else await pending; + assert.equal(removed, false); + } +}); + +test("installed version comes from bounded binary execution, not package metadata", () => { + assert.equal( + installedVersion( + "/binary", + () => { + throw new Error("must not execute"); + }, + () => false, + ), + "not installed", + ); + assert.equal( + installedVersion( + "/binary", + (binary, args, options) => { + assert.equal(binary, "/binary"); + assert.deepEqual(args, ["--version"]); + assert.equal(options.timeout, 2000); + assert.equal(options.maxBuffer, 4096); + assert.deepEqual(options.stdio, ["ignore", "pipe", "ignore"]); + return "crabcode 0.0.9\n"; + }, + () => true, + ), + "0.0.9", + ); + assert.equal( + installedVersion( + "/binary", + () => "crabcode v1.2.3-beta.1\n", + () => true, + ), + "1.2.3-beta.1", + ); + assert.equal( + installedVersion( + "/binary", + () => { + throw new Error("timeout"); + }, + () => true, + ), + "unknown", + ); + assert.equal( + installedVersion( + "/binary", + () => "\x1b[31mgarbage", + () => true, + ), + "unknown", + ); +}); diff --git a/scripts/install-ui.test.ts b/scripts/install-ui.test.ts new file mode 100644 index 0000000..6b280a3 --- /dev/null +++ b/scripts/install-ui.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + rmSync, + existsSync, + realpathSync, + symlinkSync, + readdirSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; + +const installer = resolve(import.meta.dir, "../install.sh"); +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), "crabcode-install-test-")); + dirs.push(dir); + const home = join(dir, "home"); + const tools = join(dir, "tools"); + const payload = join(dir, "payload/crabcode-x86_64-unknown-linux-gnu"); + const prefix = join(home, ".local/bin"); + for (const path of [home, tools, payload, prefix]) + mkdirSync(path, { recursive: true }); + const binary = "#!/bin/sh\nprintf 'crabcode 1.2.3\\n'\n"; + writeFileSync(join(payload, "crabcode"), binary, { mode: 0o755 }); + for (const [format, flag] of [ + ["gz", "-czf"], + ["xz", "-cJf"], + ]) { + const archive = join(dir, `archive.tar.${format}`); + const result = spawnSync("tar", [ + flag, + archive, + "-C", + join(dir, "payload"), + "crabcode-x86_64-unknown-linux-gnu", + ]); + if (result.status !== 0) throw new Error(result.stderr.toString()); + writeFileSync( + `${archive}.sha256`, + `${createHash("sha256").update(readFileSync(archive)).digest("hex")} archive.tar.${format}\n`, + ); + } + function tool(name: string, source: string) { + writeFileSync(join(tools, name), `#!/bin/sh\n${source}\n`, { mode: 0o755 }); + } + tool( + "uname", + 'case "$1" in -s) echo "${FAKE_OS:-Linux}" ;; -m) echo "${FAKE_ARCH:-x86_64}" ;; esac', + ); + tool( + "curl", + ` +printf '%s\\n' "$*" >>"$FIXTURE/curl.log" +output= progress=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --output) output=$2; shift 2 ;; + --write-out|--proto|--proto-redir|--connect-timeout|--max-time) shift 2 ;; + --progress-bar) progress=1; shift ;; + --*) shift ;; + *) url=$1; shift ;; + esac +done +case "$url" in + */releases/latest) : >"$output"; printf 'https://github.com/blankeos/crabcode/releases/tag/v1.2.3'; exit 0 ;; +esac +if [ "$progress" = 1 ] && [ "\u0024{UNKNOWN_SIZE:-0}" != 1 ]; then + printf '\\r######## 25.0%%\\r################ 100.0%%\\r' >&2 +fi +case "$url" in + *.tar.gz) [ "\u0024{MODE:-}" != fallback ] || { printf 404; echo 'curl: (22) 404' >&2; exit 22; } ;; +esac +[ "\u0024{MODE:-}" != failure ] || { echo partial >"$output"; printf 500; echo 'curl: (22) server failed' >&2; exit 22; } +case "$url" in + *.tar.gz.sha256) source="$FIXTURE/archive.tar.gz.sha256" ;; + *.tar.xz.sha256) source="$FIXTURE/archive.tar.xz.sha256" ;; + *.tar.gz) source="$FIXTURE/archive.tar.gz" ;; + *.tar.xz) source="$FIXTURE/archive.tar.xz" ;; + *) echo 'unexpected URL' >&2; exit 99 ;; +esac +case "$url" in *.sha256) + [ "\u0024{MODE:-}" != missing-checksum ] || { printf 404; exit 22; } + [ "\u0024{MODE:-}" != checksum ] || { printf '%064d\\n' 0 >"$output"; printf 200; exit 0; } ;; +esac +cp "$source" "$output" +printf 200 +`, + ); + const env = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: join(home, ".config"), + PATH: `${tools}:/usr/bin:/bin:/usr/sbin:/sbin`, + FIXTURE: dir, + TERM: "xterm", + MODE: "", + CRABCODE_INSTALL_DIR: prefix, + }; + function run( + args: string[] = ["1.2.3"], + overrides: Record = {}, + tty = false, + input?: string, + ) { + const fullEnv = { ...env, ...overrides }; + if (tty) { + // script provides a real PTY; platform flags differ between BSD and util-linux. + const shell = `${fullEnv.PTY_COLUMNS ? `stty rows 24 cols ${Number(fullEnv.PTY_COLUMNS)}; ` : ""}/bin/sh '${installer}' ${args.map((a) => `'${a}'`).join(" ")} readFileSync(join(dir, "curl.log"), "utf8"), + }; +} + +describe("POSIX prebuilt installer", () => { + test("sh syntax is valid", () => { + expect(spawnSync("/bin/sh", ["-n", installer]).status).toBe(0); + }); + test("pinned release installs gzip archive and an escaped flat receipt, normal output is stderr", () => { + const f = fixture(); + const prefix = join(f.home, 'custom "quoted" \\prefix'); + const result = f.run(["v1.2.3"], { + CRABCODE_INSTALL_DIR: prefix, + XDG_CONFIG_HOME: join(f.home, "xdg"), + }); + expect(result.status).toBe(0); + expect(result.stdout).toBe(""); + expect(result.text).toContain( + "Installed version: not installed\n\nInstalling crabcode version: 1.2.3", + ); + expect(result.text).not.toMatch(/[\x1b\r]/); + expect(readFileSync(join(prefix, "crabcode"), "utf8")).toBe(f.binary); + const receipt = JSON.parse( + readFileSync(join(f.home, "xdg/crabcode/crabcode-receipt.json"), "utf8"), + ); + expect(receipt).toMatchObject({ + binaries: ["crabcode"], + install_prefix: join(realpathSync(f.home), 'custom "quoted" \\prefix'), + install_layout: "flat", + }); + expect(f.log()).toContain( + "/v1.2.3/crabcode-x86_64-unknown-linux-gnu.tar.gz", + ); + expect(f.log()).toContain(".tar.gz.sha256"); + expect(f.log()).not.toContain("releases/latest"); + for (const line of f.log().trim().split("\n")) { + expect(line).toContain("--fail --location"); + expect(line).toContain("--proto =https --proto-redir =https"); + expect(line).toContain("--connect-timeout 10"); + expect(line).toContain( + line.includes(".sha256") ? "--max-time 20" : "--max-time 300", + ); + } + }); + test("defaults to latest, reports the current version and upgrades", () => { + const f = fixture(); + f.old(); + const result = f.run([]); + expect(result.status).toBe(0); + expect(result.text).toContain( + "Installed version: 0.9.0\n\nInstalling crabcode version: 1.2.3", + ); + expect(f.log()).toContain("releases/latest"); + expect( + f + .log() + .split("\n") + .find((line) => line.includes("releases/latest")), + ).toContain("--connect-timeout 10 --max-time 20"); + expect(readFileSync(join(f.prefix, "crabcode"), "utf8")).toBe(f.binary); + }); + test("default install prefix is ~/.local/bin", () => { + const f = fixture(); + expect(f.run(["1.2.3"], { CRABCODE_INSTALL_DIR: "" }).status).toBe(0); + expect(existsSync(join(f.prefix, "crabcode"))).toBe(true); + }); + for (const mode of ["checksum", "failure", "missing-checksum"]) { + test(`${mode} failure preserves installed executable`, () => { + const f = fixture(); + f.old(); + const before = readFileSync(join(f.prefix, "crabcode")); + const result = f.run(["1.2.3"], { MODE: mode }); + expect(result.status).not.toBe(0); + expect(result.text).toContain("Error:"); + expect(readFileSync(join(f.prefix, "crabcode"))).toEqual(before); + expect( + existsSync(join(f.home, ".config/crabcode/crabcode-receipt.json")), + ).toBe(false); + if (mode === "failure") { + expect(f.log()).not.toContain("tar.xz"); + expect(result.text).toContain("server failed"); + } + }); + } + test("only archive 404 triggers legacy xz and verifies its checksum", () => { + const f = fixture(); + expect(f.run(["1.2.3"], { MODE: "fallback" }).status).toBe(0); + expect(f.log()).toContain(".tar.xz.sha256"); + expect(readFileSync(join(f.prefix, "crabcode"), "utf8")).toBe(f.binary); + }); + for (const [os, arch, target] of [ + ["Darwin", "arm64", "aarch64-apple-darwin"], + ["Darwin", "aarch64", "aarch64-apple-darwin"], + ["Darwin", "x86_64", "x86_64-apple-darwin"], + ["Linux", "aarch64", "aarch64-unknown-linux-gnu"], + ]) { + test(`${os}/${arch} selects Rust triple`, () => { + const f = fixture(); + expect(f.run(["1.2.3"], { FAKE_OS: os, FAKE_ARCH: arch }).status).toBe(0); + expect(f.log()).toContain(`crabcode-${target}.tar.gz`); + }); + } + test("unsupported platform fails without networking", () => { + const f = fixture(); + expect(f.run([], { FAKE_OS: "FreeBSD" }).status).not.toBe(0); + expect(f.run([], { FAKE_ARCH: "riscv64" }).status).not.toBe(0); + expect(existsSync(join(f.dir, "curl.log"))).toBe(false); + }); + for (const args of [ + [""], + ["../bad"], + ["--help"], + ["1.2"], + ["1.2.3", "extra"], + ["1.2.3\nmalicious"], + ]) { + test(`rejects arguments ${JSON.stringify(args)}`, () => { + const f = fixture(); + expect(f.run(args).status).not.toBe(0); + expect(existsSync(join(f.dir, "curl.log"))).toBe(false); + }); + } + test("verified archive symlink is rejected without replacing the old binary", () => { + const f = fixture(); + f.old(); + const path = join( + f.dir, + "payload/crabcode-x86_64-unknown-linux-gnu/crabcode", + ); + rmSync(path); + symlinkSync("/etc/passwd", path); + const archive = join(f.dir, "archive.tar.gz"); + expect( + spawnSync("tar", [ + "-czf", + archive, + "-C", + join(f.dir, "payload"), + "crabcode-x86_64-unknown-linux-gnu", + ]).status, + ).toBe(0); + writeFileSync( + `${archive}.sha256`, + createHash("sha256").update(readFileSync(archive)).digest("hex") + "\n", + ); + const result = f.run(); + expect(result.status).not.toBe(0); + expect(result.text).toContain("not a regular file"); + expect(readFileSync(join(f.prefix, "crabcode"), "utf8")).toContain("0.9.0"); + expect(readdirSync(f.prefix)).toEqual(["crabcode"]); + }); + test("TTY renders real percentages, amber solid blocks, capped width", () => { + const f = fixture(); + const result = f.run(["1.2.3"], { COLUMNS: "200" }, true); + expect(result.status).toBe(0); + expect(result.text).toContain("\x1b[33m"); + expect(result.text).toContain( + "█".repeat(12) + ".".repeat(38) + " 25%\x1b[0m", + ); + expect(result.text).toContain("100%"); + expect(result.text).not.toMatch( + /\x1b\[\?25|\x1b\[\?1049|\x1b\[2J|\x1b\[2m/, + ); + expect(result.text.match(/100%/g)).toHaveLength(1); + expect(result.text).toContain("Verifying download..."); + expect(result.text.indexOf("100%")).toBeLessThan( + result.text.indexOf("Installed crabcode 1.2.3"), + ); + expect( + f + .log() + .split("\n") + .filter((line) => line.includes("--progress-bar")), + ).toHaveLength(1); + }); + test("NO_COLOR retains narrow TTY progress without ANSI", () => { + const f = fixture(); + const result = f.run(["1.2.3"], { NO_COLOR: "", COLUMNS: "16" }, true); + expect(result.status).toBe(0); + expect(result.text).not.toContain("\x1b"); + expect(result.text).toContain("██....... 25%"); + }); + for (let columns = 1; columns <= 12; columns++) { + for (const actualSize of [false, true]) { + test(`PTY progress fits ${columns} columns (${actualSize ? "stty" : "COLUMNS"})`, () => { + const f = fixture(); + const result = f.run( + ["1.2.3"], + { + NO_COLOR: "", + COLUMNS: actualSize ? undefined : String(columns), + PTY_COLUMNS: String(columns), + }, + true, + ); + expect(result.status).toBe(0); + // Isolate CR redraws from normal phase/status lines and PTY CRLF. + const redraws = [...result.text.matchAll(/\r([^\r\n]+)(?=\r)/g)].map( + (match) => match[1], + ); + if (columns === 1) expect(redraws).toHaveLength(0); + else { + expect(redraws.length).toBeGreaterThan(0); + for (const redraw of redraws) + expect([...redraw].length).toBeLessThanOrEqual(columns - 1); + } + }); + } + } + test("absent COLUMNS uses actual stderr terminal width", () => { + const f = fixture(); + const result = f.run( + ["1.2.3"], + { COLUMNS: undefined, PTY_COLUMNS: "24", NO_COLOR: "" }, + true, + ); + expect(result.status).toBe(0); + expect(result.text).toContain("█".repeat(4) + ".".repeat(13) + " 25%"); + }); + test("installed version probe is bounded to two seconds, even for a hanging child", () => { + const f = fixture(); + writeFileSync(join(f.prefix, "crabcode"), "#!/bin/sh\nexec sleep 30\n", { + mode: 0o755, + }); + const start = Date.now(); + const result = f.run(); + const elapsed = Date.now() - start; + expect(result.status).toBe(0); + expect(result.text).toContain("Installed version: unknown"); + expect(elapsed).toBeGreaterThanOrEqual(1900); + expect(elapsed).toBeLessThan(5000); + }); + test("installed version probe cannot consume piped installer stdin", () => { + const f = fixture(); + writeFileSync( + join(f.prefix, "crabcode"), + `#!/bin/sh +if read -r line; then printf '%s' "$line" >"$FIXTURE/consumed"; fi +printf 'crabcode 0.9.0\\n' +`, + { mode: 0o755 }, + ); + const result = f.run(["1.2.3"], {}, false, "sentinel input\n"); + expect(result.status).toBe(0); + expect(result.text).toContain("Installed version: 0.9.0"); + expect(existsSync(join(f.dir, "consumed"))).toBe(false); + }); + for (const output of [ + "\x1b[33mcrabcode 0.9.0\x1b[0m\n", + "crabcode 0.9.0\ncrabcode 9.9.9\n", + "crabcode 0.9.0\n\n", + "crabcode not-a-version\n", + ]) { + test(`rejects untrusted installed version ${JSON.stringify(output)}`, () => { + const f = fixture(); + writeFileSync(join(f.dir, "version-output"), output); + writeFileSync( + join(f.prefix, "crabcode"), + '#!/bin/sh\ncat "$FIXTURE/version-output"\n', + { mode: 0o755 }, + ); + const result = f.run(); + expect(result.status).toBe(0); + expect(result.text).toContain("Installed version: unknown"); + expect(result.text).not.toContain("\x1b"); + expect(result.text).not.toContain("9.9.9"); + }); + } + test("TERM=dumb stays textual even on a TTY", () => { + const f = fixture(); + const result = f.run(["1.2.3"], { TERM: "dumb" }, true); + expect(result.status).toBe(0); + expect(result.text).not.toContain("\x1b"); + expect(result.text).not.toContain("25.0%"); + expect(f.log()).toContain("--silent"); + }); + test("unknown size does not invent progress", () => { + const f = fixture(); + const result = f.run(["1.2.3"], { UNKNOWN_SIZE: "1" }, true); + expect(result.status).toBe(0); + expect(result.text).toContain("Downloading..."); + expect(result.text).not.toMatch(/\d+\.\d+%/); + }); + test("TTY curl failure retains its status and diagnostic", () => { + const f = fixture(); + f.old(); + const result = f.run(["1.2.3"], { MODE: "failure" }, true); + expect(result.status).not.toBe(0); + expect(result.text).toContain("server failed"); + expect(readFileSync(join(f.prefix, "crabcode"), "utf8")).toContain("0.9.0"); + }); +}); diff --git a/scripts/upgrade-ui.test.ts b/scripts/upgrade-ui.test.ts new file mode 100644 index 0000000..8e0ecd9 --- /dev/null +++ b/scripts/upgrade-ui.test.ts @@ -0,0 +1,319 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// Always exercise the compiled workspace CLI, never a crabcode on the host PATH. +// Build first: cargo build; bun test scripts/upgrade-ui.test.ts +const cli = resolve(import.meta.dir, "../target/debug/crabcode"); +const current = /^version = "([^"]+)"/m.exec( + readFileSync(resolve(import.meta.dir, "../Cargo.toml"), "utf8"), +)![1]; +const target = "1.2.3"; +const oldBinary = `#!/bin/sh\nprintf 'crabcode ${current}\\n'\n`; +const newBinary = `#!/bin/sh\nprintf 'crabcode ${target}\\n'\n`; +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }); +}); +const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`; +const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); + +function fixture( + layout: "default" | "custom" | "cargo-home" = "default", + drift = false, +) { + if (!existsSync(cli)) + throw new Error( + `Missing compiled CLI: ${cli}; run cargo build before this suite (no PATH fallback).`, + ); + const dir = realpathSync( + mkdtempSync(join(tmpdir(), "crabcode-upgrade-test-")), + ); + dirs.push(dir); + const home = join(dir, "home"); + const tools = join(dir, "tools"); + const cargo = join(home, ".cargo"); + const prefix = + layout === "custom" + ? join(home, "custom-flat") + : layout === "cargo-home" + ? cargo + : join(home, ".local/bin"); + const installed = join( + prefix, + ...(layout === "cargo-home" ? ["bin"] : []), + "crabcode", + ); + const config = join(home, ".config"); + for (const path of [ + tools, + dirname(installed), + join(config, "crabcode"), + join(dir, "payload"), + ]) + mkdirSync(path, { recursive: true }); + writeFileSync(installed, oldBinary, { mode: 0o755 }); + writeFileSync(join(dir, "payload/crabcode"), newBinary, { mode: 0o755 }); + const archive = join(dir, "archive.tar.gz"); + const tar = spawnSync( + "/usr/bin/tar", + ["-czf", archive, "-C", join(dir, "payload"), "crabcode"], + { encoding: "utf8" }, + ); + if (tar.status !== 0) throw new Error(`fixture tar failed: ${tar.stderr}`); + writeFileSync( + `${archive}.sha256`, + `${createHash("sha256").update(readFileSync(archive)).digest("hex")} archive.tar.gz\n`, + ); + function receipt(at: string, kind: string) { + writeFileSync( + join(config, "crabcode/crabcode-receipt.json"), + JSON.stringify({ + binaries: ["crabcode"], + binary_aliases: {}, + install_prefix: at, + install_layout: kind, + }), + ); + } + if (layout !== "default") + receipt(prefix, layout === "custom" ? "flat" : "cargo-home"); + function tool(name: string, body: string) { + writeFileSync(join(tools, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + } + // No host package manager is allowed to install anything. Historical receipt + // detection can query cargo --list, but every mutating cargo command fails. + tool( + "cargo", + '[ "$*" = "install --list" ] && exit 0; echo "unexpected cargo: $*" >&2; exit 99', + ); + tool( + "curl", + ` +set -eu +printf '%s\\n' "$*" >>"$FIXTURE/curl.log" +output= url= writeout=0 +while [ "$#" -gt 0 ]; do + case "$1" in + -o|--output) output=$2; shift 2 ;; + --write-out) writeout=1; shift 2 ;; + --proto|--proto-redir|--connect-timeout|--max-time|-H) shift 2 ;; + -*) shift ;; + *) url=$1; shift ;; + esac +done +case "$url" in + */releases/download/v1.2.3/*) ;; + *) echo "unexpected or unpinned URL: $url" >&2; exit 99 ;; +esac +if [ "\u0024{MODE:-}" = failure ]; then + [ -z "$output" ] || printf partial >"$output" + [ "$writeout" = 0 ] || printf 500 + echo 'curl: (22) HTTP 500 fixture failure' >&2 + exit 22 +fi +case "$url" in + */crabcode-installer.sh) source="$FIXTURE/dist-installer.sh" ;; + *.tar.gz.sha256) source="$FIXTURE/archive.tar.gz.sha256" ;; + *.tar.gz) sleep 0.3; source="$FIXTURE/archive.tar.gz" ;; + *) echo "unexpected asset: $url" >&2; exit 99 ;; +esac +if [ -n "$output" ]; then cp "$source" "$output"; else cat "$source"; fi +[ "$writeout" = 0 ] || printf 200 +`, + ); + writeFileSync( + join(dir, "dist-installer.sh"), + `#!/bin/sh +set -eu +[ "$#" = 0 ] || { echo 'unexpected installer arguments' >&2; exit 91; } +[ "$CRABCODE_INSTALL_DIR" = "$EXPECTED_PREFIX" ] || exit 92 +[ "$CARGO_DIST_FORCE_INSTALL_DIR" = "$EXPECTED_PREFIX" ] || exit 93 +[ "$CARGO_HOME" = "$EXPECTED_CARGO_HOME" ] || exit 94 +mkdir -p "$EXPECTED_PREFIX/bin" +cp "$FIXTURE/payload/crabcode" "$EXPECTED_PREFIX/bin/crabcode" +printf 'dist installer output\\n' +`, + { mode: 0o755 }, + ); + const cargoHome = drift ? join(home, "drifted-cargo") : cargo; + // Deliberately do not inherit CRABCODE_INSTALL_DIR, NO_COLOR, or host PATH. + const env = { + HOME: home, + XDG_CONFIG_HOME: config, + CARGO_HOME: cargoHome, + PATH: `${dirname(installed)}:${tools}:/usr/bin:/bin`, + FIXTURE: dir, + EXPECTED_PREFIX: prefix, + EXPECTED_CARGO_HOME: cargoHome, + TMPDIR: dir, + TERM: "xterm", + MODE: "", + }; + function run( + version = target, + overrides: Record = {}, + columns?: number, + ) { + const fullEnv = { ...env, ...overrides }; + let executable = cli; + let args = ["upgrade", version]; + if (columns !== undefined) { + executable = "/usr/bin/script"; + // Disable the PTY line discipline's LF->CRLF translation so CR assertions + // measure CLI rendering, rather than script's terminal transport. + const shell = `stty rows 24 cols ${columns} -onlcr; exec ${quote(cli)} upgrade ${quote(version)} + existsSync(join(dir, "curl.log")) + ? readFileSync(join(dir, "curl.log"), "utf8") + : ""; + return { dir, home, prefix, installed, cargoHome, receipt, run, log }; +} + +function successful( + f: ReturnType, + result: ReturnType["run"]>, +) { + expect(result.status).toBe(0); + expect(result.text).toContain(`Installed version: ${current}`); + expect(result.text).toContain(`Upgrading crabcode version: ${target}`); + expect(result.text).toContain("Using install.sh"); + expect(result.text).toContain( + `Upgraded crabcode to ${target}. Restart crabcode to apply.`, + ); + expect(readFileSync(f.installed, "utf8")).toBe(newBinary); + expect(existsSync(join(dirname(f.installed), "bin/crabcode"))).toBe(false); + expect(f.log()).not.toMatch(/releases\/latest|api\.github/); +} + +describe("compiled CLI upgrade UI and install routing (offline)", () => { + test("redirected UI is stderr only and installs the pinned archive in the active flat directory", () => { + const f = fixture(); + const r = f.run("v1.2.3"); + successful(f, r); + expect(r.stdout).toBe(""); + expect(r.text).toContain("Working..."); + expect(r.text).not.toMatch(/[\x1b\r%]/); + expect(f.log()).toContain("/releases/download/v1.2.3/crabcode-"); + expect(f.log()).toContain(".tar.gz.sha256"); + }); + test("HTTP 500 preserves the executable and reports failure without success or fallback", () => { + const f = fixture(); + const r = f.run(target, { MODE: "failure" }); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(""); + expect(r.text).toContain("HTTP 500 fixture failure"); + expect(r.text).not.toContain("Upgraded crabcode to"); + expect(r.text).not.toMatch(/[\x1b\r]/); + expect(readFileSync(f.installed, "utf8")).toBe(oldBinary); + expect(f.log()).not.toMatch(/latest|tar\.xz/); + }); + test("a stale receipt cannot redirect the active ~/.local/bin installation", () => { + const f = fixture(); + const other = join(f.home, "other-prefix"); + mkdirSync(other); + writeFileSync(join(other, "crabcode"), "untouched"); + f.receipt(other, "flat"); + successful(f, f.run()); + expect(readFileSync(join(other, "crabcode"), "utf8")).toBe("untouched"); + expect(existsSync(join(other, "bin"))).toBe(false); + }); + test("a matching custom flat receipt installs in place, not prefix/bin/bin", () => { + const f = fixture("custom"); + successful(f, f.run()); + }); + for (const drift of [false, true]) { + test(`historical cargo-home receipt uses the tagged dist installer with no arguments (drift=${drift})`, () => { + const f = fixture("cargo-home", drift); + const r = f.run(); + successful(f, r); + expect(r.stdout).toBe(""); + expect(f.log().trim().split("\n")).toHaveLength(1); + expect(f.log()).toContain( + "/releases/download/v1.2.3/crabcode-installer.sh", + ); + if (drift) + expect(existsSync(join(f.cargoHome, "bin/crabcode"))).toBe(false); + }); + } + test("same-version upgrade is a no-op and never invokes curl", () => { + const f = fixture(); + const r = f.run(current); + expect(r.status).toBe(0); + expect(r.stdout).toBe(""); + expect(r.text).toContain(`Already on ${current}. Nothing to upgrade.`); + expect(r.text).not.toContain("Working"); + expect(f.log()).toBe(""); + expect(readFileSync(f.installed, "utf8")).toBe(oldBinary); + }); + test("invalid version never runs an installer or curl", () => { + const f = fixture(); + const r = f.run("not-a-version"); + expect(r.status).not.toBe(0); + expect(r.stdout).toBe(""); + expect(r.text).toContain("invalid upgrade version"); + expect(f.log()).toBe(""); + expect(readFileSync(f.installed, "utf8")).toBe(oldBinary); + }); + for (const columns of [20, 40, 80]) { + test(`PTY ${columns} columns renders amber Working without fabricated percentages or wrapping`, () => { + const f = fixture(); + const r = f.run(target, {}, columns); + successful(f, r); + expect(r.text).toContain("\x1b[33m"); + expect(r.text).toContain(" Working"); + expect(r.text).not.toMatch(/\d+(?:\.\d+)?%/); + const rows = [...r.text.matchAll(/\r\x1b\[2K([^\r\n]*)/g)] + .map((m) => stripAnsi(m[1])) + .filter((row) => row.includes("Working")); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) + expect([...row].length).toBeLessThanOrEqual(columns - 1); + }); + } + test("PTY NO_COLOR suppresses SGR colors while retaining clear-line rendering", () => { + const f = fixture(); + const r = f.run(target, { NO_COLOR: "" }, 40); + successful(f, r); + expect(r.text).not.toMatch(/\x1b\[[0-9;]*m/); + expect(r.text).toContain("\x1b[2K"); + expect(r.text).toContain(" Working"); + expect(r.text).not.toContain("%"); + }); + test("PTY TERM=dumb has neither ANSI nor carriage-return rendering", () => { + const f = fixture(); + const r = f.run(target, { TERM: "dumb" }, 40); + successful(f, r); + expect(r.text).toContain("Working..."); + expect(r.text).not.toMatch(/[\x1b\r%]/); + }); +}); diff --git a/src/upgrade.rs b/src/upgrade.rs index 934269d..f5ec74f 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -7,6 +7,8 @@ use std::process::{Command, Stdio}; use anyhow::{bail, Context, Result}; use serde::Deserialize; +mod progress; + pub(crate) const GITHUB_REPO: &str = "Blankeos/crabcode"; const BREW_FORMULA: &str = "blankeos/tap/crabcode"; const NPM_PACKAGE: &str = "crabcode"; @@ -110,31 +112,21 @@ pub(crate) fn upgrade_noninteractive(target: Option<&str>) -> Result { /// Upgrade crabcode to the latest release, or to a specific target version. pub fn upgrade(target: Option<&str>) -> Result<()> { - let current = env!("CARGO_PKG_VERSION").to_string(); - let method = detect_install_method()?; - let exe = resolve_install_path()?; - - println!("→ Current version: v{current}"); - println!("→ Binary path: {}", exe.display()); - println!("→ Detected `{}`", method.label()); - + let current = current_version(); + eprintln!("Installed version: {current}"); let check = resolve_target_version(¤t, target)?; if !check.needs_upgrade { - println!("✓ Already on v{} — nothing to do.", check.current); + eprintln!("Already on {}. Nothing to upgrade.", check.current); return Ok(()); } - println!( - "→ Upgrading: v{} → {}", - check.current, - display_version(&check.target) - ); - - run_method_upgrade(&method, &check.target)?; - - println!( - "✓ Upgrade complete. Restart crabcode to use {}.", - display_version(&check.target) + let method = detect_install_method()?; + eprintln!("\nUpgrading crabcode version: {}", check.target); + eprintln!("Using {}", method.label()); + progress::run(|| run_method_upgrade_captured(&method, &check.target))?; + eprintln!( + "Upgraded crabcode to {}. Restart crabcode to apply.", + check.target ); Ok(()) } @@ -150,6 +142,7 @@ fn resolve_target_version(current: &str, target: Option<&str>) -> Result String { version.trim().trim_start_matches('v').to_string() } -fn display_version(version: &str) -> String { - format!("v{}", normalize_version(version)) -} - /// Bounded GitHub lookup for the upgrade target (mirrors the 10s update /// check). Null stdin + no-prompt env so a background upgrade thread can /// never hang forever waiting on a prompt; `--max-time` bounds the network. @@ -325,6 +314,9 @@ fn detect_install_method_from_path_impl( } // Heuristics when path alone is ambiguous + if load_receipt().as_ref().is_some_and(|r| r.claims_path(path)) { + return InstallMethod::InstallScript; + } if brew_claims() { return InstallMethod::Homebrew; } @@ -410,18 +402,6 @@ fn detect_js_manager_strict() -> Option { } } -fn run_method_upgrade(method: &InstallMethod, target_version: &str) -> Result<()> { - match method { - InstallMethod::Homebrew => upgrade_brew(target_version), - InstallMethod::Js { manager } => upgrade_js(manager, target_version), - InstallMethod::Cargo { use_binstall } => upgrade_cargo(*use_binstall, target_version), - InstallMethod::InstallScript => upgrade_install_script(target_version), - InstallMethod::Unknown { path } => { - bail!("{}", unknown_method_help(path)); - } - } -} - fn unknown_method_help(path: &Path) -> String { format!( "could not determine install method for `{}`.\n\ @@ -434,8 +414,8 @@ fn unknown_method_help(path: &Path) -> String { ) } -/// Captured mirror of [`run_method_upgrade`] for TUI use: nothing inherits the -/// terminal, nothing prints, nothing can prompt. +/// Shared runner for CLI and TUI: package-manager output never overwrites the +/// progress row or active application, and missing input fails instead of hanging. fn run_method_upgrade_captured(method: &InstallMethod, target_version: &str) -> Result<()> { match method { InstallMethod::Homebrew => upgrade_brew_captured(target_version), @@ -516,17 +496,21 @@ fn upgrade_install_script_captured(target_version: &str) -> Result<()> { #[cfg(not(windows))] { let tag = format!("v{}", normalize_version(target_version)); - let url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/{tag}/{BINARY_NAME}-installer.sh" - ); - let latest_url = format!( - "https://github.com/{GITHUB_REPO}/releases/latest/download/{BINARY_NAME}-installer.sh" - ); - - let installer_bytes = download_bytes_captured(&url) - .filter(|bytes| !bytes.is_empty()) - .or_else(|| download_bytes_captured(&latest_url).filter(|bytes| !bytes.is_empty())) - .context("failed to download installer")?; + let path = resolve_install_path()?; + let receipt = matching_receipt(load_shell_install_receipt(), &path); + let flat = receipt + .as_ref() + .is_none_or(|receipt| receipt.install_layout == "flat"); + let installer_bytes = if flat { + include_bytes!("../install.sh").to_vec() + } else { + let url = format!( + "https://github.com/{GITHUB_REPO}/releases/download/{tag}/{BINARY_NAME}-installer.sh" + ); + download_bytes_captured(&url) + .filter(|bytes| !bytes.is_empty()) + .context("failed to download installer")? + }; // Secure temp-file execution (no stdin-pipe deadlock): // the old code wrote the whole script to the child's stdin while @@ -547,17 +531,25 @@ fn upgrade_install_script_captured(target_version: &str) -> Result<()> { let mut cmd = Command::new("sh"); cmd.arg(script_file.path()) - .arg(&tag) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .env("NONINTERACTIVE", "1") .env("GIT_TERMINAL_PROMPT", "0"); + if flat { + cmd.arg(&tag); + } // Pin the reinstall to the original receipt prefix so an upgrade // overwrites the same destination even if CARGO_HOME drifted since // install. Both the per-app var and the generic dist override are // set; unknown installer versions ignore unknown env harmlessly. - if let Some(prefix) = load_shell_install_receipt().and_then(|r| r.reinstall_prefix()) { + let prefix = receipt.and_then(|r| r.reinstall_prefix()).or_else(|| { + resolve_install_path() + .ok()? + .parent() + .map(|path| path.to_string_lossy().into_owned()) + }); + if let Some(prefix) = prefix { cmd.env("CRABCODE_INSTALL_DIR", &prefix); cmd.env("CARGO_DIST_FORCE_INSTALL_DIR", &prefix); } @@ -634,154 +626,6 @@ fn run_command_captured(program: &str, args: &[&str]) -> Result<()> { Ok(()) } -fn upgrade_brew(target_version: &str) -> Result<()> { - if !command_exists("brew") { - bail!("detected Homebrew install, but `brew` is not on PATH"); - } - - // Third-party formulae typically only track latest; specific versions aren't pin-installable. - let _ = target_version; - let args = ["upgrade", BREW_FORMULA]; - println!("→ Doing `brew {}`", args.join(" ")); - run_command("brew", &args) -} - -fn upgrade_js(manager: &JsPackageManager, target_version: &str) -> Result<()> { - let spec = format!("{NPM_PACKAGE}@{target_version}"); - let (bin, args) = manager.install_global_cmd(&spec); - if !command_exists(&bin) { - bail!( - "detected `{}` install, but `{bin}` is not on PATH", - manager.as_str() - ); - } - println!("→ Doing `{} {}`", bin, args.join(" ")); - run_command(&bin, &args.iter().map(String::as_str).collect::>()) -} - -fn upgrade_cargo(use_binstall: bool, target_version: &str) -> Result<()> { - if use_binstall && command_exists("cargo-binstall") { - let args = [ - "binstall".to_string(), - "-y".to_string(), - format!("{BINARY_NAME}@{target_version}"), - ]; - println!("→ Doing `cargo {}`", args.join(" ")); - return run_command( - "cargo", - &args.iter().map(String::as_str).collect::>(), - ); - } - - if !command_exists("cargo") { - bail!("detected cargo install, but `cargo` is not on PATH"); - } - - let args = [ - "install".to_string(), - BINARY_NAME.to_string(), - "--locked".to_string(), - "--force".to_string(), - "--version".to_string(), - target_version.to_string(), - ]; - println!("→ Doing `cargo {}`", args.join(" ")); - run_command( - "cargo", - &args.iter().map(String::as_str).collect::>(), - ) -} - -fn upgrade_install_script(target_version: &str) -> Result<()> { - #[cfg(windows)] - { - let _ = target_version; - bail!( - "automatic upgrades via install script are not supported on Windows; \ - reinstall with npm, cargo, or the latest GitHub release" - ); - } - - #[cfg(not(windows))] - { - let tag = format!("v{}", normalize_version(target_version)); - let url = format!( - "https://github.com/{GITHUB_REPO}/releases/download/{tag}/{BINARY_NAME}-installer.sh" - ); - let latest_url = format!( - "https://github.com/{GITHUB_REPO}/releases/latest/download/{BINARY_NAME}-installer.sh" - ); - - println!("→ Doing `curl ... | sh -s -- {tag}`"); - - let script = Command::new("curl") - .args(["-fsSL", "--max-time", "25", &url]) - .stdin(Stdio::null()) - .stderr(Stdio::piped()) - .env("GIT_TERMINAL_PROMPT", "0") - .output() - .context("failed to download installer")?; - - let installer_bytes = if script.status.success() && !script.stdout.is_empty() { - script.stdout - } else { - let fallback = Command::new("curl") - .args(["-fsSL", "--max-time", "25", &latest_url]) - .stdin(Stdio::null()) - .stderr(Stdio::piped()) - .env("GIT_TERMINAL_PROMPT", "0") - .output() - .context("failed to download installer")?; - if !fallback.status.success() { - bail!( - "failed to download installer: {}", - String::from_utf8_lossy(&fallback.stderr) - ); - } - fallback.stdout - }; - - let mut child = Command::new("sh") - .arg("-s") - .arg("--") - .arg(&tag) - .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .context("failed to start installer shell")?; - - use std::io::Write; - child - .stdin - .as_mut() - .context("failed to open installer stdin")? - .write_all(&installer_bytes) - .context("failed to pipe installer script")?; - - let status = child.wait().context("installer failed to run")?; - if !status.success() { - bail!("installer exited with {status}"); - } - Ok(()) - } -} - -fn run_command(program: &str, args: &[&str]) -> Result<()> { - let status = Command::new(program) - .args(args) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status() - .with_context(|| format!("failed to run `{program}`"))?; - - if !status.success() { - bail!("`{program} {}` failed with {status}", args.join(" ")); - } - Ok(()) -} - fn command_exists(name: &str) -> bool { #[cfg(unix)] { @@ -962,10 +806,12 @@ impl InstallReceipt { if prefix.as_os_str().is_empty() { return false; } - // cargo-home layout installs to `/bin`; flat layouts install - // directly under prefix. `starts_with(prefix)` covers both without - // guessing layout strings. - binary_path.starts_with(&prefix) + let directory = match self.install_layout.as_str() { + "flat" => prefix, + "cargo-home" | "hierarchical" => prefix.join("bin"), + _ => return false, + }; + binary_path.parent() == Some(directory.as_path()) } /// Prefix to force on reinstall so the upgrade overwrites the original @@ -979,6 +825,10 @@ impl InstallReceipt { } } +fn matching_receipt(receipt: Option, path: &Path) -> Option { + receipt.filter(|receipt| receipt.claims_path(path)) +} + fn expand_receipt_prefix(raw: &str) -> PathBuf { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -1321,6 +1171,48 @@ mod tests { assert_eq!(check.target, "0.0.10"); } + #[test] + fn rejects_invalid_upgrade_versions() { + for version in ["--quiet", "1.2.3/../../latest", "", "1.2", "1.2.3\nother"] { + assert!(resolve_target_version("1.0.0", Some(version)).is_err()); + } + } + + #[test] + fn detects_receipt_owned_custom_prefix() { + let path = PathBuf::from("/custom/install/bin/crabcode"); + let receipt = InstallReceipt { + binaries: vec![BINARY_NAME.into()], + install_prefix: "/custom/install/bin".into(), + install_layout: "flat".into(), + ..Default::default() + }; + let method = detect_with( + &path, + &fake_cargo_bin(), + Some(receipt), + false, + false, + None, + false, + ); + assert_eq!(method, InstallMethod::InstallScript); + } + + #[test] + fn stale_or_nested_receipts_cannot_redirect_upgrade() { + let receipt = shell_receipt_for("/custom/install"); + assert!(receipt.claims_path(Path::new("/custom/install/bin/crabcode"))); + assert!(!receipt.claims_path(Path::new("/custom/install/other/bin/crabcode"))); + assert!( + matching_receipt(Some(receipt), Path::new("/home/me/.local/bin/crabcode")).is_none() + ); + let mut flat = shell_receipt_for("/custom/install"); + flat.install_layout = "flat".into(); + assert!(flat.claims_path(Path::new("/custom/install/crabcode"))); + assert!(!flat.claims_path(Path::new("/custom/install/bin/crabcode"))); + } + #[test] fn current_version_matches_package() { assert_eq!(current_version(), env!("CARGO_PKG_VERSION")); diff --git a/src/upgrade/progress.rs b/src/upgrade/progress.rs new file mode 100644 index 0000000..266081d --- /dev/null +++ b/src/upgrade/progress.rs @@ -0,0 +1,98 @@ +use std::io::{self, IsTerminal, Write}; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::Result; + +pub(super) fn run(work: impl FnOnce() -> Result<()>) -> Result<()> { + let interactive = + io::stderr().is_terminal() && std::env::var("TERM").is_ok_and(|term| term != "dumb"); + if !interactive { + eprintln!("Working..."); + return work(); + } + + let color = std::env::var_os("NO_COLOR").is_none(); + std::thread::scope(|scope| { + let (sender, receiver) = mpsc::channel(); + // Closing the channel on unwind also stops and joins the renderer. + let stop = StopOnDrop(sender); + let renderer = scope.spawn(move || { + let mut tick = 0; + loop { + let columns = ratatui::crossterm::terminal::size() + .map(|(columns, _)| usize::from(columns)) + .unwrap_or(80); + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{}", working_row(columns, tick, color)); + let _ = stderr.flush(); + drop(stderr); + match receiver.recv_timeout(Duration::from_millis(120)) { + Err(mpsc::RecvTimeoutError::Timeout) => tick += 1, + _ => break, + } + } + let _ = write!(io::stderr(), "\r\x1b[2K"); + let _ = io::stderr().flush(); + }); + let result = work(); + drop(stop); + let _ = renderer.join(); + result + }) +} + +struct StopOnDrop(mpsc::Sender<()>); + +impl Drop for StopOnDrop { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +fn working_row(columns: usize, tick: usize, color: bool) -> String { + if columns < 12 { + return "Working".chars().take(columns.saturating_sub(1)).collect(); + } + let width = columns.saturating_sub(10).min(50); + let start = tick % width; + let bar: String = (0..width) + .map(|index| if index == start { '█' } else { '.' }) + .collect(); + if color { + format!("\x1b[33m{bar}\x1b[0m Working") + } else { + format!("{bar} Working") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn working_progress_has_no_fabricated_percentage() { + let row = working_row(80, 17, false); + assert_eq!(row.chars().count(), 58); + assert_eq!(row.matches('█').count(), 1); + assert!(row.ends_with(" Working")); + assert!(!row.contains('%')); + assert!(!row.contains('\x1b')); + assert_ne!(row, working_row(80, 18, false)); + } + + #[test] + fn working_progress_fits_narrow_terminals() { + for columns in 0..100 { + let row = working_row(columns, usize::MAX, false); + assert!(row.chars().count() <= columns.saturating_sub(1)); + } + } + + #[test] + fn color_only_wraps_the_bar() { + let row = working_row(80, 0, true); + assert!(row.starts_with("\x1b[33m█")); + assert!(row.ends_with("\x1b[0m Working")); + } +}