diff --git a/.gitattributes b/.gitattributes index 94f480d..dd908cd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ -* text=auto eol=lf \ No newline at end of file +* text=auto eol=lf +install/install.sh linguist-detectable=false +install/install.ps1 linguist-detectable=false \ No newline at end of file diff --git a/README.md b/README.md index 44d43fa..1ee42c8 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,30 @@ # shell-use -> [!WARNING] -> **Work in progress.** `shell-use` is still being built out, so commands and behavior may change between releases & installation instructions may not yet work. - `shell-use` is a rust powered cli for controlling, inspecting, testing, and recording shell sessions and terminal apps. It supports all standard terminal actions (send keys, mouse clicks) & user actions (screenshot, record sessions), & testing (matches screenshot, contains text). `shell-use` supports Windows, Linux, & macOS and it supports a wide range of shells (see [Supported shells](#supported-shells)). ## Install -### homebrew (macOS/linux) +### install script + +macOS / Linux: ```sh -brew tap microsoft/shell-use https://github.com/microsoft/shell-use -brew install shell-use +curl --proto '=https' --tlsv1.2 -LsSf https://raw.githubusercontent.com/microsoft/shell-use/main/install/install.sh | sh +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/microsoft/shell-use/main/install/install.ps1 | iex ``` -### winget (windows) +Use `SHELL_USE_VERSION` to select a specific version or `SHELL_USE_INSTALL_DIR` to select an install location. + +### homebrew (macOS/linux) ```sh -winget install Microsoft.ShellUse +brew tap microsoft/shell-use https://github.com/microsoft/shell-use +brew install shell-use ``` ### download from releases diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 0000000..1a03530 --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,201 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$repository = "microsoft/shell-use" +$binaryName = "shell-use.exe" + +if ($env:OS -ne "Windows_NT") { + throw "install.ps1 only supports Windows. Use install.sh on macOS or Linux." +} + +$processorArchitecture = $env:PROCESSOR_ARCHITEW6432 +if ([string]::IsNullOrWhiteSpace($processorArchitecture)) { + $processorArchitecture = $env:PROCESSOR_ARCHITECTURE +} + +switch ($processorArchitecture.ToUpperInvariant()) { + "AMD64" { $architecture = "x86_64" } + "X86_64" { $architecture = "x86_64" } + "ARM64" { $architecture = "aarch64" } + "AARCH64" { $architecture = "aarch64" } + default { throw "Unsupported Windows architecture: $processorArchitecture" } +} + +$target = "$architecture-pc-windows-msvc" +$asset = "shell-use-$target.zip" +$version = $env:SHELL_USE_VERSION + +if ([string]::IsNullOrWhiteSpace($version) -or $version -eq "latest") { + $releaseUrl = "https://github.com/$repository/releases/latest/download" +} +else { + if (-not $version.StartsWith("v")) { + $version = "v$version" + } + $releaseUrl = "https://github.com/$repository/releases/download/$version" +} + +$downloadUrl = "$releaseUrl/$asset" +$token = $env:GITHUB_TOKEN +if ([string]::IsNullOrWhiteSpace($token)) { + $token = $env:GH_TOKEN +} + +$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("shell-use-" + [Guid]::NewGuid()) +$archivePath = Join-Path $tempDir $asset +$extractDir = Join-Path $tempDir "extract" + +New-Item -ItemType Directory -Path $extractDir -Force | Out-Null + +try { + $request = @{ + Uri = $downloadUrl + OutFile = $archivePath + UseBasicParsing = $true + } + if (-not [string]::IsNullOrWhiteSpace($token)) { + $request.Headers = @{ Authorization = "Bearer $token" } + } + + Write-Host "Downloading shell-use for $target..." + Invoke-WebRequest @request + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force + + $files = @(Get-ChildItem -LiteralPath $extractDir -File -Recurse) + if ($files.Count -ne 1 -or $files[0].Name -ne $binaryName) { + throw "Downloaded archive has unexpected contents." + } + + $installDir = $env:SHELL_USE_INSTALL_DIR + if ([string]::IsNullOrWhiteSpace($installDir)) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is not set. Set SHELL_USE_INSTALL_DIR to a writable directory." + } + $installDir = Join-Path $env:LOCALAPPDATA "Programs\shell-use\bin" + } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $destination = Join-Path $installDir $binaryName + $stagedDestination = Join-Path $installDir ".$binaryName.tmp" + + Copy-Item -LiteralPath $files[0].FullName -Destination $stagedDestination -Force + Unblock-File -LiteralPath $stagedDestination + Move-Item -LiteralPath $stagedDestination -Destination $destination -Force + + Write-Host "Installed shell-use to $destination" + + function Test-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $normalizedEntry = $Entry.TrimEnd("\") + foreach ($pathEntry in $PathValue.Split(";")) { + if ($pathEntry.Trim().TrimEnd("\") -ieq $normalizedEntry) { + return $true + } + } + return $false + } + + function Publish-EnvironmentChange { + if (-not ("ShellUseInstaller.NativeMethods" -as [Type])) { + Add-Type -Namespace ShellUseInstaller -Name NativeMethods -MemberDefinition @' +[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] +public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, + uint Msg, + UIntPtr wParam, + string lParam, + uint fuFlags, + uint uTimeout, + out UIntPtr lpdwResult); +'@ + } + + $result = [UIntPtr]::Zero + [ShellUseInstaller.NativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x1a, + [UIntPtr]::Zero, + "Environment", + 2, + 5000, + [ref]$result + ) | Out-Null + } + + function Get-UserPath { + $environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment") + if ($null -eq $environmentKey) { + return $null + } + + try { + return $environmentKey.GetValue( + "Path", + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + ) + } + finally { + $environmentKey.Dispose() + } + } + + function Set-UserPath { + param([string]$Value) + + $environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true) + if ($null -eq $environmentKey) { + throw "Could not open the current user's environment registry key." + } + + try { + $valueKind = [Microsoft.Win32.RegistryValueKind]::String + if ($Value.Contains("%")) { + $valueKind = [Microsoft.Win32.RegistryValueKind]::ExpandString + } + elseif ($null -ne $environmentKey.GetValue("Path")) { + $valueKind = $environmentKey.GetValueKind("Path") + } + + $environmentKey.SetValue("Path", $Value, $valueKind) + } + finally { + $environmentKey.Dispose() + } + + Publish-EnvironmentChange + } + + if (-not (Test-PathEntry -PathValue $env:Path -Entry $installDir)) { + $env:Path = "$installDir;$env:Path" + } + + $skipPathUpdate = $env:SHELL_USE_NO_MODIFY_PATH -match "^(1|true|yes)$" + if (-not $skipPathUpdate) { + $userPath = Get-UserPath + if (-not (Test-PathEntry -PathValue $userPath -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($userPath)) { + $newUserPath = $installDir + } + else { + $newUserPath = "$installDir;$userPath" + } + Set-UserPath -Value $newUserPath + Write-Host "Added $installDir to PATH. Restart your shell." + } + } + elseif (-not (Test-PathEntry -PathValue (Get-UserPath) -Entry $installDir)) { + Write-Host "Add $installDir to PATH." + } +} +finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/install/install.sh b/install/install.sh new file mode 100644 index 0000000..708e6ed --- /dev/null +++ b/install/install.sh @@ -0,0 +1,156 @@ +#!/bin/sh +set -eu + +REPOSITORY="microsoft/shell-use" +BINARY_NAME="shell-use" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +command -v uname >/dev/null 2>&1 || fail "uname is required." +command -v tar >/dev/null 2>&1 || fail "tar is required." + +case "$(uname -s)" in + Darwin) OS="apple-darwin" ;; + Linux) OS="unknown-linux-musl" ;; + MINGW*|MSYS*|CYGWIN*) + fail "Use install.ps1 to install shell-use on Windows." + ;; + *) fail "Unsupported operating system: $(uname -s)" ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64" ;; + arm64|aarch64) ARCH="aarch64" ;; + *) fail "Unsupported architecture: $(uname -m)" ;; +esac + +TARGET="${ARCH}-${OS}" +ASSET="${BINARY_NAME}-${TARGET}.tar.gz" +VERSION="${SHELL_USE_VERSION:-latest}" + +if [ -z "$VERSION" ] || [ "$VERSION" = "latest" ]; then + RELEASE_URL="https://github.com/${REPOSITORY}/releases/latest/download" +else + case "$VERSION" in + v*) ;; + *) VERSION="v${VERSION}" ;; + esac + RELEASE_URL="https://github.com/${REPOSITORY}/releases/download/${VERSION}" +fi + +DOWNLOAD_URL="${RELEASE_URL}/${ASSET}" +TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}" +TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t shell-use)" +ARCHIVE_PATH="${TMP_DIR}/${ASSET}" +EXTRACT_DIR="${TMP_DIR}/extract" + +cleanup() { + rm -rf -- "$TMP_DIR" +} +trap cleanup EXIT HUP INT TERM + +download() { + url="$1" + destination="$2" + + if command -v curl >/dev/null 2>&1; then + if [ -n "$TOKEN" ]; then + curl --proto '=https' --tlsv1.2 -fsSL \ + -H "Authorization: Bearer ${TOKEN}" \ + "$url" -o "$destination" + else + curl --proto '=https' --tlsv1.2 -fsSL "$url" -o "$destination" + fi + elif command -v wget >/dev/null 2>&1; then + if [ -n "$TOKEN" ]; then + wget -q --header="Authorization: Bearer ${TOKEN}" \ + -O "$destination" "$url" + else + wget -q -O "$destination" "$url" + fi + else + fail "curl or wget is required." + fi +} + +echo "Downloading shell-use for ${TARGET}..." +download "$DOWNLOAD_URL" "$ARCHIVE_PATH" || + fail "Could not download ${DOWNLOAD_URL}" + +ARCHIVE_CONTENTS="$(tar -tzf "$ARCHIVE_PATH")" || + fail "Downloaded archive is invalid." +case "$ARCHIVE_CONTENTS" in + "$BINARY_NAME"|"./$BINARY_NAME") ;; + *) fail "Downloaded archive has unexpected contents." ;; +esac + +mkdir -p "$EXTRACT_DIR" +tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_DIR" + +if [ -n "${SHELL_USE_INSTALL_DIR:-}" ]; then + INSTALL_DIR="$SHELL_USE_INSTALL_DIR" +elif [ -n "${PREFIX:-}" ]; then + INSTALL_DIR="${PREFIX}/bin" +elif [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ]; then + INSTALL_DIR="/usr/local/bin" +else + : "${HOME:?HOME is required when installing without root privileges.}" + INSTALL_DIR="${HOME}/.local/bin" +fi + +mkdir -p "$INSTALL_DIR" || + fail "Could not create ${INSTALL_DIR}. Set SHELL_USE_INSTALL_DIR to a writable directory." + +DESTINATION="${INSTALL_DIR}/${BINARY_NAME}" +STAGED_DESTINATION="${INSTALL_DIR}/.${BINARY_NAME}.tmp.$$" +cp "${EXTRACT_DIR}/${BINARY_NAME}" "$STAGED_DESTINATION" +chmod 755 "$STAGED_DESTINATION" +mv -f "$STAGED_DESTINATION" "$DESTINATION" + +echo "Installed shell-use to ${DESTINATION}" + +case ":${PATH:-}:" in + *":${INSTALL_DIR}:"*) exit 0 ;; +esac + +CURRENT_SHELL="$(basename "${SHELL:-/bin/sh}")" +case "$CURRENT_SHELL" in + zsh) + RC_FILE="${ZDOTDIR:-$HOME}/.zprofile" + PATH_LINE="export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; + bash) + if [ -f "$HOME/.bash_profile" ]; then + RC_FILE="$HOME/.bash_profile" + elif [ -f "$HOME/.bash_login" ]; then + RC_FILE="$HOME/.bash_login" + else + RC_FILE="$HOME/.profile" + fi + PATH_LINE="export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; + fish) + RC_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/shell-use.fish" + PATH_LINE="fish_add_path \"${INSTALL_DIR}\"" + ;; + *) + RC_FILE="$HOME/.profile" + PATH_LINE="export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; +esac + +case "${SHELL_USE_NO_MODIFY_PATH:-}" in + 1|true|TRUE|yes|YES) + echo "Add ${INSTALL_DIR} to PATH." + exit 0 + ;; +esac + +mkdir -p "$(dirname "$RC_FILE")" +if ! grep -Fqx "$PATH_LINE" "$RC_FILE" 2>/dev/null; then + printf "\n%s\n" "$PATH_LINE" >>"$RC_FILE" + echo "Added ${INSTALL_DIR} to PATH. Restart your shell." +fi