diff --git a/install/bash/examples.sh b/install/bash/examples.sh index b70a8a48..92dfe938 100755 --- a/install/bash/examples.sh +++ b/install/bash/examples.sh @@ -15,13 +15,70 @@ NC="${NC:-\033[0m}" EXAMPLES_FOLDER_NAME="plainlang-examples" EXAMPLES_DOWNLOAD_URL="https://codeplain.ai/examples/unix" +# Prompt the user, reading from the terminal when there is one. Falling back to +# stdin keeps the script usable in non-interactive environments instead of +# dying on an unreadable /dev/tty. +prompt_user() { + local prompt="$1" + local varname="$2" + + # Test that /dev/tty can actually be opened: it exists but fails to open + # when the process has no controlling terminal. + if { : < /dev/tty; } 2>/dev/null; then + read -r -p "$prompt" "$varname" < /dev/tty || true + else + read -r -p "$prompt" "$varname" || true + fi +} + +# Make the user acknowledge that the examples were not installed. The +# installation continues either way, but the failure must not scroll past +# unseen - the installer clears the screen in its next step. +confirm_continue_after_failure() { + echo "" + echo -e " ${YELLOW}The examples were not installed.${NC} ${GRAY}The installation will continue.${NC}" + echo "" + prompt_user "$(printf '%b' " Continue? Press ${WHITE}[Enter]${NC} to acknowledge: ")" _ACKNOWLEDGED + echo "" +} + +# Tell the user how to get unzip on their system, then let the installer move on. +report_missing_unzip_tool() { + echo -e " ${RED}✗ Cannot extract the examples: no 'unzip' command found.${NC}" + echo "" + echo -e " ${GRAY}Install unzip and re-run the installer to get the examples:${NC}" + echo "" + case "$OSTYPE" in + darwin*) + echo -e " ${WHITE}${BOLD}brew install unzip${NC}" + ;; + *) + echo -e " ${WHITE}${BOLD}apt-get install unzip${NC} ${GRAY}(Debian/Ubuntu)${NC}" + echo -e " ${WHITE}${BOLD}dnf install unzip${NC} ${GRAY}(Fedora/RHEL)${NC}" + echo -e " ${WHITE}${BOLD}apk add unzip${NC} ${GRAY}(Alpine)${NC}" + ;; + esac + echo "" + echo -e " ${GRAY}The examples are also available at:${NC}" + echo -e " ${WHITE}https://github.com/Codeplain-ai/plainlang-examples${NC}" + echo "" +} + +# Checked before anything is downloaded, so a missing unzip is reported up +# front instead of leaving the user with an installer that just stops. +if ! command -v unzip &> /dev/null; then + report_missing_unzip_tool + confirm_continue_after_failure + exit 0 +fi + # Show current directory and ask for extraction path CURRENT_DIR=$(pwd) echo -e " Current folder: ${WHITE}${CURRENT_DIR}${NC}" echo "" echo -e " Extract examples here, or enter a different path:" echo "" -read -r -p " [Enter for current, or type path]: " EXTRACT_PATH < /dev/tty +prompt_user " [Enter for current, or type path]: " EXTRACT_PATH echo "" # Use current directory if empty @@ -37,36 +94,49 @@ SKIP_DOWNLOAD=false # Check if directory exists, create if not if [ ! -d "$EXTRACT_PATH" ]; then echo -e " ${GRAY}Creating directory...${NC}" - mkdir -p "$EXTRACT_PATH" 2>/dev/null - if [ $? -ne 0 ]; then + if ! mkdir -p "$EXTRACT_PATH" 2>/dev/null; then echo -e " ${RED}✗ Failed to create directory: ${EXTRACT_PATH}${NC}" - echo -e " ${GRAY}Skipping example download.${NC}" + confirm_continue_after_failure SKIP_DOWNLOAD=true fi fi +EXAMPLES_INSTALLED=false + if [ "$SKIP_DOWNLOAD" = false ]; then echo -e " ${GRAY}Downloading examples...${NC}" # Download the zip file TEMP_ZIP=$(mktemp) - curl -L -s -o "$TEMP_ZIP" "$EXAMPLES_DOWNLOAD_URL" + DOWNLOAD_OK=false + DOWNLOAD_ERROR="Could not download ${EXAMPLES_DOWNLOAD_URL}" + if curl -L -s -o "$TEMP_ZIP" "$EXAMPLES_DOWNLOAD_URL" && [ -s "$TEMP_ZIP" ]; then + DOWNLOAD_OK=true + fi + + # A server error page is a successful download of the wrong thing, so check + # for the zip magic bytes rather than handing unzip an HTML page. + if [ "$DOWNLOAD_OK" = true ] && [ "$(head -c 2 "$TEMP_ZIP")" != "PK" ]; then + DOWNLOAD_OK=false + DOWNLOAD_ERROR="The downloaded file is not a zip archive." + fi - if [ $? -eq 0 ] && [ -s "$TEMP_ZIP" ]; then + if [ "$DOWNLOAD_OK" = true ]; then echo -e " ${GRAY}Extracting to ${EXTRACT_PATH}...${NC}" # Extract the zip file (contents are at the zip root, so extract into the target folder) EXTRACTED_DIR="${EXTRACT_PATH}/${EXAMPLES_FOLDER_NAME}" - rm -rf "$EXTRACTED_DIR" 2>/dev/null # Remove existing if present - unzip -q -o "$TEMP_ZIP" -d "$EXTRACTED_DIR" 2>/dev/null + rm -rf "$EXTRACTED_DIR" 2>/dev/null || true # Remove existing if present - if [ $? -eq 0 ]; then + # Keep the extractor's own error output: it is the only clue about why + # extraction failed (corrupt download, no disk space, no permissions). + if EXTRACT_OUTPUT=$(unzip -q -o "$TEMP_ZIP" -d "$EXTRACTED_DIR" 2>&1); then # Remove the .gitignore file from the root of the extracted directory if [ -f "${EXTRACTED_DIR}/.gitignore" ]; then rm -f "${EXTRACTED_DIR}/.gitignore" fi - clear + clear || true echo "" echo -e " ${GREEN}✓ Examples downloaded successfully!${NC}" echo "" @@ -80,18 +150,26 @@ if [ "$SKIP_DOWNLOAD" = false ]; then echo "" echo -e " ${GRAY}See hello-world/python/README.md for details.${NC}" echo "" + EXAMPLES_INSTALLED=true else echo -e " ${RED}✗ Failed to extract examples.${NC}" + if [ -n "$EXTRACT_OUTPUT" ]; then + echo -e " ${GRAY}$(echo "$EXTRACT_OUTPUT" | tail -n 3)${NC}" + fi fi # Clean up temp file rm -f "$TEMP_ZIP" else echo -e " ${RED}✗ Failed to download examples.${NC}" + echo -e " ${GRAY}${DOWNLOAD_ERROR}${NC}" rm -f "$TEMP_ZIP" fi - echo "" - PROMPT=$(printf '%b' " Press ${WHITE}[Enter]${NC} to continue...") - read -r -p "$PROMPT" < /dev/tty + if [ "$EXAMPLES_INSTALLED" = true ]; then + echo "" + prompt_user "$(printf '%b' " Press ${WHITE}[Enter]${NC} to continue...")" _CONTINUE + else + confirm_continue_after_failure + fi fi diff --git a/install/bash/install.sh b/install/bash/install.sh index dfd371e2..5082970b 100755 --- a/install/bash/install.sh +++ b/install/bash/install.sh @@ -425,9 +425,11 @@ else echo "" fi -# Run examples download if user agrees +# Run examples download if user agrees. The examples are a nice-to-have, so a +# failure there must never abort the installation (set -e would otherwise take +# the whole installer down without printing anything). if [[ ! "${DOWNLOAD_EXAMPLES:-}" =~ ^[Nn]$ ]]; then - run_script "examples.sh" + run_script "examples.sh" || echo -e "${GRAY}Example download did not complete. Continuing with the installation.${NC}" fi # Final verification: make sure the installed codeplain can actually run and diff --git a/install/powershell/examples.ps1 b/install/powershell/examples.ps1 index 7e64c85c..c9b0f3e2 100644 --- a/install/powershell/examples.ps1 +++ b/install/powershell/examples.ps1 @@ -19,6 +19,39 @@ $CROSS = [char]0x2717 $EXAMPLES_FOLDER_NAME = "plainlang-examples" $EXAMPLES_DOWNLOAD_URL = "https://codeplain.ai/examples/windows" +# Make the user acknowledge that the examples were not installed. The +# installation continues either way, but the failure must not scroll past +# unseen - the installer clears the screen in its next step. +function Confirm-ContinueAfterFailure { + Write-Host "" + Write-Host " ${YELLOW}The examples were not installed.${NC} ${GRAY}The installation will continue.${NC}" + Write-Host "" + Read-Host " Continue? Press ${WHITE}[Enter]${NC} to acknowledge" + Write-Host "" +} + +# Expand-Archive lives in the Microsoft.PowerShell.Archive module, shipped with +# PowerShell 5.0 and later. Checked before anything is downloaded, so a machine +# without it is told what to do instead of just seeing an extraction failure. +function Show-MissingArchiveCmdletMessage { + Write-Host " ${RED}${CROSS} Cannot extract the examples: Expand-Archive is not available.${NC}" + Write-Host "" + Write-Host " ${GRAY}Expand-Archive requires PowerShell 5.0 or later. Install a newer${NC}" + Write-Host " ${GRAY}PowerShell and re-run the installer to get the examples:${NC}" + Write-Host "" + Write-Host " ${WHITE}${BOLD}https://aka.ms/powershell${NC}" + Write-Host "" + Write-Host " ${GRAY}The examples are also available at:${NC}" + Write-Host " ${WHITE}https://github.com/Codeplain-ai/plainlang-examples${NC}" + Write-Host "" +} + +if (-not (Get-Command Expand-Archive -ErrorAction SilentlyContinue)) { + Show-MissingArchiveCmdletMessage + Confirm-ContinueAfterFailure + exit 0 +} + # Show current directory and ask for extraction path $CURRENT_DIR = Get-Location Write-Host " Current folder: ${WHITE}${CURRENT_DIR}${NC}" @@ -47,11 +80,13 @@ if (-not (Test-Path $EXTRACT_PATH -PathType Container)) { New-Item -ItemType Directory -Path $EXTRACT_PATH -Force | Out-Null } catch { Write-Host " ${RED}${CROSS} Failed to create directory: ${EXTRACT_PATH}${NC}" - Write-Host " ${GRAY}Skipping example download.${NC}" + Confirm-ContinueAfterFailure $SKIP_DOWNLOAD = $true } } +$EXAMPLES_INSTALLED = $false + if (-not $SKIP_DOWNLOAD) { Write-Host " ${GRAY}Downloading examples...${NC}" @@ -60,7 +95,26 @@ if (-not $SKIP_DOWNLOAD) { try { Invoke-WebRequest -Uri $EXAMPLES_DOWNLOAD_URL -OutFile $TEMP_ZIP -UseBasicParsing + # Check for the zip magic bytes rather than handing Expand-Archive a + # server error page. Read via a stream: 'Get-Content -Encoding Byte' is + # Windows PowerShell 5.1 only, '-AsByteStream' is PowerShell 6+ only. + $IS_ZIP = $false if (Test-Path $TEMP_ZIP) { + $HEADER = New-Object byte[] 2 + $STREAM = [System.IO.File]::OpenRead($TEMP_ZIP) + try { + $BYTES_READ = $STREAM.Read($HEADER, 0, 2) + } finally { + $STREAM.Close() + } + $IS_ZIP = ($BYTES_READ -eq 2 -and $HEADER[0] -eq 0x50 -and $HEADER[1] -eq 0x4B) + } + + if (-not $IS_ZIP -and (Test-Path $TEMP_ZIP)) { + Write-Host " ${RED}${CROSS} Failed to download examples.${NC}" + Write-Host " ${GRAY}The downloaded file is not a zip archive.${NC}" + Remove-Item -Path $TEMP_ZIP -Force -ErrorAction SilentlyContinue + } elseif ($IS_ZIP) { Write-Host " ${GRAY}Extracting to ${EXTRACT_PATH}...${NC}" try { @@ -91,8 +145,12 @@ if (-not $SKIP_DOWNLOAD) { Write-Host "" Write-Host " ${GRAY}See hello-world/python/README.md for details.${NC}" Write-Host "" + $EXAMPLES_INSTALLED = $true } catch { + # Keep the cmdlet's own message: it is the only clue about why + # extraction failed (corrupt download, no space, no permissions). Write-Host " ${RED}${CROSS} Failed to extract examples.${NC}" + Write-Host " ${GRAY}$($_.Exception.Message)${NC}" } Remove-Item -Path $TEMP_ZIP -Force -ErrorAction SilentlyContinue @@ -104,6 +162,10 @@ if (-not $SKIP_DOWNLOAD) { Remove-Item -Path $TEMP_ZIP -Force -ErrorAction SilentlyContinue } - Write-Host "" - Read-Host " Press ${WHITE}[Enter]${NC} to continue..." + if ($EXAMPLES_INSTALLED) { + Write-Host "" + Read-Host " Press ${WHITE}[Enter]${NC} to continue..." + } else { + Confirm-ContinueAfterFailure + } } diff --git a/install/powershell/install.ps1 b/install/powershell/install.ps1 index 7f9f2e33..d5d068f6 100644 --- a/install/powershell/install.ps1 +++ b/install/powershell/install.ps1 @@ -526,9 +526,16 @@ if ($nonInteractive) { Write-Host "" } -# Run examples download if user agrees +# Run examples download if user agrees. The examples are a nice-to-have, so a +# failure there must never abort the installation: examples.ps1 runs in this +# runspace via the call operator, so a terminating error inside it would +# otherwise propagate here and take the whole installer down. if ($downloadExamples -notmatch '^[Nn]$') { - Invoke-SubScript "examples.ps1" + try { + Invoke-SubScript "examples.ps1" + } catch { + Write-Host "${GRAY}Example download did not complete. Continuing with the installation.${NC}" + } } # Final verification: make sure the installed codeplain can actually run and