diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 0e50a89e3..48f27a292 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -209,8 +209,10 @@ jobs: label: unix - os: windows-latest label: windows-x64 + bun-target-arch: x64 - os: windows-11-arm label: windows-arm64 + bun-target-arch: arm64 runs-on: ${{ matrix.os }} @@ -227,24 +229,10 @@ jobs: - name: Install bun uses: oven-sh/setup-bun@v2 - if: ${{ matrix.os != 'windows-11-arm' }} with: bun-version-file: .bun-version no-cache: true - - name: Install bun (Windows ARM) - if: ${{ matrix.os == 'windows-11-arm' }} - run: | - $bunVersion = (Get-Content .bun-version).Trim() - irm bun.sh/install.ps1 -OutFile install.ps1 - - # Bun doesn't have Windows ARM64 builds, so the setup-bun action fails. The install script however, - # does "support" it. - - .\install.ps1 -Version $bunVersion - - Join-Path (Resolve-Path ~).Path ".bun\bin" >> $env:GITHUB_PATH - # https://github.com/oven-sh/bun/issues/11198 - name: Fix cross-platform building on Actions if: ${{ matrix.os != 'ubuntu-latest' }} @@ -252,8 +240,10 @@ jobs: mkdir C:\test cd C:\test bun init -y - bun build --compile --target=bun-windows-x64 --outfile test index.ts - bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts + if ("${{ matrix.bun-target-arch }}" -eq "x64") { + bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + } - name: Build Bundles run: pnpm run insert-cli-metadata && pnpm run build-bundles diff --git a/.github/workflows/pre_release.yaml b/.github/workflows/pre_release.yaml index 5347daae6..43f582e87 100644 --- a/.github/workflows/pre_release.yaml +++ b/.github/workflows/pre_release.yaml @@ -102,8 +102,10 @@ jobs: label: unix - os: windows-latest label: windows-x64 + bun-target-arch: x64 - os: windows-11-arm label: windows-arm64 + bun-target-arch: arm64 runs-on: ${{ matrix.os }} @@ -125,23 +127,9 @@ jobs: - name: Install bun uses: oven-sh/setup-bun@v2 - if: ${{ matrix.os != 'windows-11-arm' }} with: bun-version-file: .bun-version - - name: Install bun (Windows ARM) - if: ${{ matrix.os == 'windows-11-arm' }} - run: | - $bunVersion = (Get-Content .bun-version).Trim() - irm bun.sh/install.ps1 -OutFile install.ps1 - - # Bun doesn't have Windows ARM64 builds, so the setup-bun action fails. The install script however, - # does "support" it. - - .\install.ps1 -Version $bunVersion - - Join-Path (Resolve-Path ~).Path ".bun\bin" >> $env:GITHUB_PATH - # https://github.com/oven-sh/bun/issues/11198 - name: Fix cross-platform building on Actions if: ${{ matrix.os != 'ubuntu-latest' }} @@ -149,8 +137,10 @@ jobs: mkdir C:\test cd C:\test bun init -y - bun build --compile --target=bun-windows-x64 --outfile test index.ts - bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts + if ("${{ matrix.bun-target-arch }}" -eq "x64") { + bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + } - name: Set pre-release version run: pnpm version ${{ needs.update_changelog.outputs.pre_release_version }} --no-git-tag-version --allow-same-version diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 56c71a89f..4e7a4ee3f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -130,8 +130,10 @@ jobs: label: unix - os: windows-latest label: windows-x64 + bun-target-arch: x64 - os: windows-11-arm label: windows-arm64 + bun-target-arch: arm64 runs-on: ${{ matrix.os }} @@ -153,23 +155,9 @@ jobs: - name: Install bun uses: oven-sh/setup-bun@v2 - if: ${{ matrix.os != 'windows-11-arm' }} with: bun-version-file: .bun-version - - name: Install bun (Windows ARM) - if: ${{ matrix.os == 'windows-11-arm' }} - run: | - $bunVersion = (Get-Content .bun-version).Trim() - irm bun.sh/install.ps1 -OutFile install.ps1 - - # Bun doesn't have Windows ARM64 builds, so the setup-bun action fails. The install script however, - # does "support" it. - - .\install.ps1 -Version $bunVersion - - Join-Path (Resolve-Path ~).Path ".bun\bin" >> $env:GITHUB_PATH - # https://github.com/oven-sh/bun/issues/11198 - name: Fix cross-platform building on Actions if: ${{ matrix.os != 'ubuntu-latest' }} @@ -177,8 +165,10 @@ jobs: mkdir C:\test cd C:\test bun init -y - bun build --compile --target=bun-windows-x64 --outfile test index.ts - bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts + if ("${{ matrix.bun-target-arch }}" -eq "x64") { + bun build --compile --target=bun-windows-x64-baseline --outfile test index.ts + } - name: Set version run: pnpm version ${{ needs.release_metadata.outputs.version_number }} --no-git-tag-version --allow-same-version diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 5ae25d03d..848db6179 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -8,10 +8,10 @@ When node stabilizes SEA (https://nodejs.org/api/single-executable-applications. */ import { readFileSync } from 'node:fs'; -import { readFile, rm, writeFile } from 'node:fs/promises'; +import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { $, type Build, build, fileURLToPath } from 'bun'; +import { type Build, build, fileURLToPath } from 'bun'; import { version } from '../package.json' with { type: 'json' }; @@ -21,23 +21,24 @@ const targets = (() => { // 'bun-windows-x64', 'bun-windows-x64-baseline', + 'bun-windows-arm64', 'bun-linux-x64', 'bun-linux-x64-baseline', 'bun-linux-arm64', - 'bun-linux-arm64-baseline', 'bun-darwin-x64', 'bun-darwin-x64-baseline', 'bun-darwin-arm64', - 'bun-darwin-arm64-baseline', 'bun-linux-x64-musl', 'bun-linux-arm64-musl', - // TODO: when adding native windows arm64 builds, remove these too - 'bun-linux-x64-musl-baseline' as never, - 'bun-linux-arm64-musl-baseline' as never, + 'bun-linux-x64-baseline-musl', ] satisfies Build.CompileTarget[]; } if (process.platform === 'win32') { + if (process.arch === 'arm64') { + return ['bun-windows-arm64'] satisfies Build.CompileTarget[]; + } + return ['bun-windows-x64', 'bun-windows-x64-baseline'] satisfies Build.CompileTarget[]; } @@ -45,24 +46,26 @@ const targets = (() => { 'bun-linux-x64', 'bun-linux-x64-baseline', 'bun-linux-arm64', - 'bun-linux-arm64-baseline', 'bun-darwin-x64', 'bun-darwin-x64-baseline', 'bun-darwin-arm64', - 'bun-darwin-arm64-baseline', 'bun-linux-x64-musl', 'bun-linux-arm64-musl', - 'bun-linux-x64-musl-baseline' as never, - 'bun-linux-arm64-musl-baseline' as never, + 'bun-linux-x64-baseline-musl', ] satisfies Build.CompileTarget[]; })(); +// We now build a single `apify-cli` bundle. The `apify` and `actor` CLIs are wrapper scripts (created on +// install/upgrade) that invoke this bundle with `APIFY_CLI_ENTRYPOINT` set to pick the command set. const entryPoints = [ // - fileURLToPath(new URL('../src/entrypoints/apify.ts', import.meta.url)), - fileURLToPath(new URL('../src/entrypoints/actor.ts', import.meta.url)), + fileURLToPath(new URL('../src/entrypoints/apify-cli.ts', import.meta.url)), ]; +// Names under which a copy of the single bundle is also published, so that installs using the old +// two-bundle upgrade flow can still pull the new bundle. These can be dropped once everyone has migrated. +const backupBundleNames = ['apify', 'actor']; + await rm(new URL('../bundles/', import.meta.url), { recursive: true, force: true }); // #region Inject the fact the CLI is ran in a bundle, instead of installed through npm/volta @@ -109,33 +112,16 @@ for (const entryPoint of entryPoints) { } for (const target of targets) { - // eslint-disable-next-line prefer-const -- somehow it cannot tell that os and arch cannot be "const" while the rest are let - let [, os, arch, musl, baseline] = target.split('-'); - - if (musl === 'baseline') { - musl = ''; - baseline = 'baseline'; - } - - // If we are building on Windows ARM64, even though the target is x64, we mark it as "arm64" (there are some weird errors when compiling on x64 - // and running on arm64). Hopefully bun will get arm64 native builds - // TODO: Vlad remove this in a subsequent PR as Bun now has native arm64 windows builds - if (os === 'windows' && process.platform === 'win32') { - const systemType = await $`pwsh -c "(Get-CimInstance Win32_ComputerSystem).SystemType"`.text(); - - if (systemType.toLowerCase().includes('arm')) { - arch = 'arm64'; + // `target` is a bun compile target like `bun-linux-x64-baseline-musl`. The trailing modifiers (libc + // and/or SIMD level) can appear in any order, so collect them and emit the asset suffix in a stable + // `-musl-baseline` order (which the install/upgrade asset matchers rely on). + const [, os, arch, ...modifiers] = target.split('-'); - // On arm, process.arch will still return x64, which will break the upgrade command. - // So we override the arch to arm64 + const isMusl = modifiers.includes('musl'); + const isBaseline = modifiers.includes('baseline'); - const newNewContent = newContent.replace('process.env.APIFY_BUNDLE_ARCH', '"arm64"'); - - await writeFile(metadataFile, newNewContent); - } - } - - const fileName = `${cliName}-${version}-${os}-${arch}${musl ? '-musl' : ''}${baseline ? '-baseline' : ''}`; + const versionSuffix = `${version}-${os}-${arch}${isMusl ? '-musl' : ''}${isBaseline ? '-baseline' : ''}`; + const fileName = `${cliName}-${versionSuffix}`; const outFile = fileURLToPath(new URL(`../bundles/${fileName}`, import.meta.url)); @@ -156,8 +142,18 @@ for (const entryPoint of entryPoints) { bytecode: true, }); - // Remove the arch override - await writeFile(metadataFile, newContent); + // Bun appends `.exe` to the output file for Windows targets + const isWindowsTarget = os === 'windows'; + const compiledFile = `${outFile}${isWindowsTarget ? '.exe' : ''}`; + + // Publish copies of the single bundle under the legacy `apify`/`actor` names as a backup + for (const backupName of backupBundleNames) { + const backupFile = fileURLToPath( + new URL(`../bundles/${backupName}-${versionSuffix}${isWindowsTarget ? '.exe' : ''}`, import.meta.url), + ); + + await copyFile(compiledFile, backupFile); + } } } diff --git a/scripts/install/dev-test-install-legacy.sh b/scripts/install/dev-test-install-legacy.sh new file mode 100644 index 000000000..4d0faa9a2 --- /dev/null +++ b/scripts/install/dev-test-install-legacy.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script installs the CLI in the OLD layout (three full binaries: apify, actor, apify-cli) so you +# can test the single-bundle self-migration locally. It is identical to dev-test-install.sh except for +# the install section. +# +# After running it, your bin directory will hold the legacy 3-binary state. Run `apify` (or `actor`) +# afterwards to trigger the migration to the new single-bundle layout (set APIFY_CLI_DEBUG=1 to watch). +# +# This script should be used from the repo root like so: `cat scripts/install/dev-test-install-legacy.sh | bash` + +# Reset +Color_Off='' + +# Regular Colors +Red='' +Dim='' # White + +if [[ -t 1 ]]; then + # Reset + Color_Off='\033[0m' # Text Reset + + # Regular Colors + Red='\033[0;31m' # Red + Dim='\033[0;2m' # White +fi + +error() { + echo -e "${Red}error${Color_Off}:" "$@" >&2 + exit 1 +} + +info() { + echo -e "${Dim}$@ ${Color_Off}" +} + +platform=$(uname -ms) + +case $platform in +'Darwin x86_64') + target=darwin-x64 + ;; +'Darwin arm64') + target=darwin-arm64 + ;; +'Linux aarch64' | 'Linux arm64') + target=linux-arm64 + ;; +'MINGW64'*) + target=windows-x64 + ;; +'Linux x86_64' | *) + target=linux-x64 + ;; +esac + +case "$target" in +'linux'*) + if [ -f /etc/alpine-release ]; then + target="$target-musl" + fi + ;; +esac + +# If AVX2 isn't supported, use the -baseline build +case "$target" in +'darwin-x64'*) + if [[ $(sysctl -a | grep machdep.cpu | grep AVX2) == '' ]]; then + target="$target-baseline" + fi + ;; +'linux-x64'*) + # If AVX2 isn't supported, use the -baseline build + if [[ $(cat /proc/cpuinfo | grep avx2) = '' ]]; then + target="$target-baseline" + fi + ;; +esac + +install_env=APIFY_CLI_INSTALL +install_dir=${!install_env:-$HOME/.apify} +bin_dir=$install_dir/bin + +if [[ ! -d $bin_dir ]]; then + mkdir -p "$bin_dir" || + error "Failed to create install directory \"$bin_dir\"" +fi + +# Ensure we are in the apify-cli root by checking for ./package.json +if [[ ! -f ./package.json ]]; then + error "Not in the apify-cli root" +fi + +echo "Install directory: $install_dir" +echo "Bin directory: $bin_dir" + +# Ensure we have bun installed +if ! command -v bun &> /dev/null; then + error "bun could not be found. Please install it from https://bun.sh/docs/installation" + exit 1 +fi + +# Ensure we have jq installed +if ! command -v jq &> /dev/null; then + error "jq could not be found. Please install it from https://stedolan.github.io/jq/" + exit 1 +fi +# Check package.json for the version +version=$(jq -r '.version' package.json) +echo "Version: $version" + +info "Installing dependencies" +pnpm install + +info "Building bundles" +pnpm run insert-cli-metadata && pnpm run build-bundles && git checkout -- src/lib/hooks/useCLIMetadata.ts + +info "Installing bundles in the legacy 3-binary layout" + +# Reproduce the OLD install layout: `apify` and `actor` are full binaries, and `apify-cli` is a copy of +# `apify` (the alias the old install scripts created). The build now emits backwards-compatible +# `apify-*`/`actor-*` copies of the single bundle, so we use those to populate apify and actor. +for executable_name in apify actor; do + info "Installing $executable_name bundle for version $version and target $target" + + cp "bundles/$executable_name-$version-$target" "$bin_dir/$executable_name" + chmod +x "$bin_dir/$executable_name" +done + +# Alias apify to apify-cli, as the old install scripts did +cp "$bin_dir/apify" "$bin_dir/apify-cli" +chmod +x "$bin_dir/apify-cli" + +# NOTE: we intentionally do NOT run `apify install` here. The freshly built bundle contains the +# self-migration logic, which runs on the first invocation of any command and would immediately convert +# this legacy layout into the new single-bundle layout - defeating the purpose of this script. +info "Legacy 3-binary layout installed:" +ls -la "$bin_dir/apify" "$bin_dir/actor" "$bin_dir/apify-cli" + +echo "" +info "To test the migration, run a command and watch the debug output, e.g.:" +echo " APIFY_CLI_DEBUG=1 \"$bin_dir/apify\" --version" +echo "" +info "Afterwards, apify and actor should become small wrapper scripts and apify-cli the only binary." diff --git a/scripts/install/dev-test-install.sh b/scripts/install/dev-test-install.sh index f96819825..edef975a0 100644 --- a/scripts/install/dev-test-install.sh +++ b/scripts/install/dev-test-install.sh @@ -109,21 +109,18 @@ pnpm install info "Building bundles" pnpm run insert-cli-metadata && pnpm run build-bundles && git checkout -- src/lib/hooks/useCLIMetadata.ts -info "Installing bundles" +info "Installing bundle" -executable_names=("apify" "actor") +# We now ship a single `apify-cli` bundle. The `apify` and `actor` commands are tiny wrapper scripts +# that invoke it with APIFY_CLI_ENTRYPOINT set, instead of dropping the same binary three times. +info "Installing apify-cli bundle for version $version and target $target" -for executable_name in "${executable_names[@]}"; do - output_filename="${executable_name}" - - info "Installing $executable_name bundle for version $version and target $target" - - cp "bundles/$executable_name-$version-$target" "$bin_dir/$output_filename" - chmod +x "$bin_dir/$output_filename" -done +cp "bundles/apify-cli-$version-$target" "$bin_dir/apify-cli" +chmod +x "$bin_dir/apify-cli" +# Invoke the bundle to create the `apify`/`actor` wrapper scripts and handle shell integration. if ! [ -t 0 ] && [ -r /dev/tty ]; then - PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_OPEN_TTY=1 "$bin_dir/apify" install + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_CLI_SKIP_UPDATE_CHECK=1 APIFY_OPEN_TTY=1 "$bin_dir/apify-cli" install else - PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" "$bin_dir/apify" install + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_CLI_SKIP_UPDATE_CHECK=1 "$bin_dir/apify-cli" install fi diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 335023341..828dc4543 100755 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -8,21 +8,15 @@ param( # The following script is adapted from the bun.sh install script # Licensed under the MIT License (https://github.com/oven-sh/bun/blob/main/LICENSE.md) -$allowedSystemTypes = @("x64-based", "ARM64-based") -$currentSystemType = (Get-CimInstance Win32_ComputerSystem).SystemType +$Arch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').PROCESSOR_ARCHITECTURE # filter out 32 bit -if (-not ($allowedSystemTypes | Where-Object { $currentSystemType -match $_ })) { +if (-not ($Arch -eq "AMD64" -or $Arch -eq "ARM64")) { Write-Output "Install Failed:" Write-Output "Apify CLI for Windows is currently only available for 64-bit Windows and ARM64 Windows.`n" return 1 } -if ($currentSystemType -match "ARM64") { - Write-Warning "Warning:" - Write-Warning "ARM64-based systems are not natively supported yet.`nThe install will still continue but Apify CLI might not work as intended.`n" -} - # This corresponds to .win10_rs5 in build.zig $MinBuild = 17763; $MinBuildName = "Windows 10 1809 / Windows Server 2019" @@ -114,31 +108,45 @@ function Install-Apify { return 1 } - $Arch = if ($currentSystemType -match "ARM64") { "arm64" } else { "x64" } - $IsBaseline = $ForceBaseline + $IsARM64 = $Arch -eq "ARM64" + $Arch = if ($IsARM64) { "arm64" } else { "x64" } + $IsBaseline = $false - if (-not $IsBaseline) { - $IsBaseline = !( - Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);' -Name 'Kernel32' -Namespace 'Win32' -PassThru - )::IsProcessorFeaturePresent(40) + # Baseline (non-AVX2) builds only exist for x64; native ARM64 bundles never need them. + if (-not $IsARM64) { + $IsBaseline = $ForceBaseline + + if (-not $IsBaseline) { + $IsBaseline = !( + Add-Type -MemberDefinition '[DllImport("kernel32.dll")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);' -Name 'Kernel32' -Namespace 'Win32' -PassThru + )::IsProcessorFeaturePresent(40) + } } $ApifyRoot = if ($env:APIFY_CLI_INSTALL) { $env:APIFY_CLI_INSTALL } else { "${Home}\.apify" } $ApifyBin = mkdir -Force "${ApifyRoot}\bin" try { + # Remove any previously installed binaries and wrapper scripts (including legacy ones from the + # old two-bundle layout, and `.old` leftovers from a self-migration). foreach ($ExecutableName in $ExecutableNames) { - Remove-Item "${ApifyBin}\${ExecutableName}.exe" -Force + Remove-Item "${ApifyBin}\${ExecutableName}.exe" -Force -ErrorAction Ignore + Remove-Item "${ApifyBin}\${ExecutableName}.exe.old" -Force -ErrorAction Ignore + Remove-Item "${ApifyBin}\${ExecutableName}.cmd" -Force -ErrorAction Ignore } - # Alias apify to apify-cli, as npm does (because otherwise npx apify-cli wouldn't work) - Remove-Item "${ApifyBin}\apify-cli.exe" -Force + # apify-cli.exe is the canonical binary. We guard the removal with Test-Path so a fresh install + # doesn't error on a missing file, but deliberately let a lock error surface (no -ErrorAction + # Ignore) so the UnauthorizedAccessException handler below can tell the user to close the running CLI. + if (Test-Path "${ApifyBin}\apify-cli.exe") { + Remove-Item "${ApifyBin}\apify-cli.exe" -Force + } } catch [System.Management.Automation.ItemNotFoundException] { # ignore } catch [System.UnauthorizedAccessException] { - $openProcesses = Get-Process -Name apify | Where-Object { $_.Path -eq "${ApifyBin}\apify.exe" } + $openProcesses = Get-Process -Name apify, apify-cli -ErrorAction Ignore | Where-Object { $_.Path -like "${ApifyBin}\*" } if ($openProcesses.Count -gt 0) { Write-Output "Install Failed - An older installation exists and is open. Please close open Apify CLI processes and try again." return 1 @@ -167,55 +175,66 @@ function Install-Apify { $null = mkdir -Force $ApifyBin - foreach ($ExecutableName in $ExecutableNames) { - $FileName = "${ExecutableName}.exe" - $Target = "${ExecutableName}-${Version}-windows-${Arch}${IsBaseline ? '-baseline' : ''}" + # We now ship a single `apify-cli.exe` bundle. The `apify` and `actor` commands are `.cmd` wrapper + # scripts that invoke it with APIFY_CLI_ENTRYPOINT set, instead of dropping the same binary three times. + $FileName = "apify-cli.exe" + $Target = "apify-cli-${Version}-windows-${Arch}${IsBaseline ? '-baseline' : ''}" - $DownloadURL = "${BaseURL}${Target}.exe" - $DownloadPath = "${ApifyBin}\${FileName}" + $DownloadURL = "${BaseURL}${Target}.exe" + $DownloadPath = "${ApifyBin}\${FileName}" - curl.exe "-#SfLo" "$DownloadPath" "$DownloadURL" + curl.exe "-#SfLo" "$DownloadPath" "$DownloadURL" - if ($LASTEXITCODE -ne 0) { - Write-Warning "The command 'curl.exe $DownloadURL -o $DownloadPath' exited with code ${LASTEXITCODE}`nTrying an alternative download method..." + if ($LASTEXITCODE -ne 0) { + Write-Warning "The command 'curl.exe $DownloadURL -o $DownloadPath' exited with code ${LASTEXITCODE}`nTrying an alternative download method..." - try { - # Use Invoke-RestMethod instead of Invoke-WebRequest because Invoke-WebRequest breaks on - # some machines - Invoke-RestMethod -Uri $DownloadURL -OutFile $DownloadPath - } - catch { - Write-Output "Install Failed - could not download $DownloadURL" - Write-Output "The command 'Invoke-RestMethod $DownloadURL -OutFile $DownloadPath' exited with code ${LASTEXITCODE}`n" - return 1 - } + try { + # Use Invoke-RestMethod instead of Invoke-WebRequest because Invoke-WebRequest breaks on + # some machines + Invoke-RestMethod -Uri $DownloadURL -OutFile $DownloadPath } - - $ApifyVersion = "$(& "${ApifyBin}\${FileName}" --version)" - if ($LASTEXITCODE -eq 1073741795) { - # STATUS_ILLEGAL_INSTRUCTION - if ($IsBaseline) { - Write-Output "Install Failed - apify.exe (baseline) is not compatible with your CPU.`n" - return 1 - } - - Write-Output "Install Failed - apify.exe is not compatible with your CPU. This should have been detected before downloading.`n" - Write-Output "Attempting to download apify.exe (baseline) instead.`n" - - Install-Apify -Version $Version -ForceBaseline $True + catch { + Write-Output "Install Failed - could not download $DownloadURL" + Write-Output "The command 'Invoke-RestMethod $DownloadURL -OutFile $DownloadPath' exited with code ${LASTEXITCODE}`n" return 1 } + } - if ($LASTEXITCODE -ne 0) { - Write-Output "Install Failed - could not verify apify.exe" - Write-Output "The command '${ApifyBin}\apify.exe --version' exited with code ${LASTEXITCODE}`n" + $ApifyVersion = "$(& "${ApifyBin}\${FileName}" --version)" + if ($LASTEXITCODE -eq 1073741795) { + # STATUS_ILLEGAL_INSTRUCTION + if ($IsBaseline) { + Write-Output "Install Failed - apify-cli.exe (baseline) is not compatible with your CPU.`n" return 1 } - if ($ExecutableName -eq "apify") { - # Alias apify to apify-cli, as npm does (because otherwise npx apify-cli wouldn't work) - Copy-Item -Path "${ApifyBin}\${FileName}" -Destination "${ApifyBin}\apify-cli.exe" -Force - } + Write-Output "Install Failed - apify-cli.exe is not compatible with your CPU. This should have been detected before downloading.`n" + Write-Output "Attempting to download apify-cli.exe (baseline) instead.`n" + + Install-Apify -Version $Version -ForceBaseline $True + return 1 + } + + if ($LASTEXITCODE -ne 0) { + Write-Output "Install Failed - could not verify apify-cli.exe" + Write-Output "The command '${ApifyBin}\apify-cli.exe --version' exited with code ${LASTEXITCODE}`n" + return 1 + } + + # Let the bundle create the `apify`/`actor` wrapper scripts (.cmd, .ps1 and a POSIX shim for Git Bash). + # Keeping the shim content in the bundle avoids duplicating it across the install/upgrade scripts. + # Skip the bundle's automatic version check here - we just downloaded the requested version. + $prevSkipCheck = $env:APIFY_CLI_SKIP_UPDATE_CHECK + $env:APIFY_CLI_SKIP_UPDATE_CHECK = "1" + try { + & "${ApifyBin}\${FileName}" install --shims-only + } + finally { + $env:APIFY_CLI_SKIP_UPDATE_CHECK = $prevSkipCheck + } + if ($LASTEXITCODE -ne 0) { + Write-Output "Install Failed - could not create the apify/actor wrapper scripts (exit code ${LASTEXITCODE})`n" + return 1 } $UpgradeScriptPath = "${ApifyBin}\upgrade.ps1" @@ -241,20 +260,20 @@ function Install-Apify { $C_DIM = [char]27 + "[0;2m" Write-Output "${C_GREEN}Apify and Actor CLI ${ApifyVersion} were installed successfully!${C_RESET}" - Write-Output "${C_DIM}The binaries are located at ${ApifyBin}\apify.exe and ${ApifyBin}\actor.exe${C_RESET}`n" + Write-Output "${C_DIM}The bundle is located at ${ApifyBin}\apify-cli.exe (invoked via the apify.cmd and actor.cmd wrappers)${C_RESET}`n" $hasExistingOther = $false; try { $existing = Get-Command apify -ErrorAction - if ($existing.Source -ne "${ApifyBin}\apify.exe") { - Write-Warning "Note: Another apify.exe is already in %PATH% at $($existing.Source)`nTyping 'apify' in your terminal will not use what was just installed.`n" + if ($existing.Source -ne "${ApifyBin}\apify.cmd") { + Write-Warning "Note: Another apify is already in %PATH% at $($existing.Source)`nTyping 'apify' in your terminal will not use what was just installed.`n" $hasExistingOther = $true; } } catch {} if (!$hasExistingOther) { - # Only try adding to path if there isn't already a apify.exe in the path + # Only try adding to path if there isn't already an apify in the path $Path = (Get-Env -Key "Path") -split ';' if ($Path -notcontains $ApifyBin) { $Path += $ApifyBin diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 9717f2487..d425bfb99 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -58,6 +58,9 @@ case $platform in 'Linux aarch64' | 'Linux arm64') target=linux-arm64 ;; +'MINGW64'*'ARM64'* | 'MINGW64'*'aarch64'*) + target=windows-arm64 + ;; 'MINGW64'*) target=windows-x64 ;; @@ -117,8 +120,6 @@ fetch_latest_version() { echo "$version" } -executable_names=("apify" "actor") - if [[ $# = 0 || $1 = "latest" ]]; then version=$(fetch_latest_version) else @@ -133,14 +134,6 @@ else version=${version#v} fi -# Function to construct download URL -construct_download_url() { - local cli_name="$1" - local edition="$2" - - echo "https://github.com/apify/apify-cli/releases/download/v${version}/${cli_name}-${version}-${edition}" -} - install_env=APIFY_CLI_INSTALL install_dir=${!install_env:-$HOME/.apify} bin_dir=$install_dir/bin @@ -150,30 +143,24 @@ if [[ ! -d $bin_dir ]]; then error "Failed to create install directory \"$bin_dir\"" fi -for executable_name in "${executable_names[@]}"; do - download_url=$(construct_download_url "$executable_name" "$target") - output_filename="${executable_name}" - - info "Downloading $executable_name bundle for version $version and target $target" +# We now ship a single `apify-cli` bundle. The `apify` and `actor` commands are tiny wrapper scripts +# that invoke it with APIFY_CLI_ENTRYPOINT set, instead of dropping the same binary three times. +download_url="https://github.com/apify/apify-cli/releases/download/v${version}/apify-cli-${version}-${target}" - curl --fail --location --progress-bar --output "$bin_dir/$output_filename" "$download_url" || - error "Failed to download $executable_name bundle for version $version and target $target (might not exist for this platform/arch combination)" +info "Downloading apify-cli bundle for version $version and target $target" - chmod +x "$bin_dir/$output_filename" || - error "Failed to set permissions on $executable_name executable" +curl --fail --location --progress-bar --output "$bin_dir/apify-cli" "$download_url" || + error "Failed to download apify-cli bundle for version $version and target $target (might not exist for this platform/arch combination)" - # Alias apify to apify-cli, as npm does (because otherwise npx apify-cli wouldn't work) - if [[ $executable_name = "apify" ]]; then - cp "$bin_dir/$output_filename" "$bin_dir/apify-cli" - fi -done +chmod +x "$bin_dir/apify-cli" || + error "Failed to set permissions on apify-cli executable" -# Invoke the CLI to handle shell integrations nicely +# Invoke the bundle to create the `apify`/`actor` wrapper scripts and handle shell integration. # When running the script via `curl xxx | bash`, stdin is the script that gets consumed by bash. # If stdin is not a tty and we have a readable /dev/tty, tell Node.js to open /dev/tty itself # (shell-level redirects don't support raw mode properly for Node.js/Inquirer). if ! [ -t 0 ] && [ -r /dev/tty ]; then - PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_OPEN_TTY=1 "$bin_dir/apify" install + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_CLI_SKIP_UPDATE_CHECK=1 APIFY_OPEN_TTY=1 "$bin_dir/apify-cli" install else - PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" "$bin_dir/apify" install + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_CLI_SKIP_UPDATE_CHECK=1 "$bin_dir/apify-cli" install fi diff --git a/scripts/install/upgrade.ps1 b/scripts/install/upgrade.ps1 index 983fc0332..273131f3c 100644 --- a/scripts/install/upgrade.ps1 +++ b/scripts/install/upgrade.ps1 @@ -17,8 +17,8 @@ $UpgradeScriptURL = "https://raw.githubusercontent.com/apify/apify-cli/refs/head $URLArray = $AllUrls -split ',' -if ($URLArray.Count -ne 2) { - Write-Error "URL parameter must contain exactly 2 comma-delimited URLs" +if ($URLArray.Count -lt 1) { + Write-Error "URL parameter must contain at least 1 URL" exit 1 } @@ -116,13 +116,20 @@ function Download-File-To-Location { } -foreach ($URL in $URLArray) { - $URLSplit = $URL -split '/' - $FullCLIName = $URLSplit[-1] +# We now ship a single `apify-cli` bundle. Download it (the URL list may contain backwards-compatible +# backup URLs too, but they are all copies of the same bundle, so the first one is enough). +Download-File-To-Location -URL $URLArray[0] -FileName "apify-cli" -Location $InstallLocation -Type 0 -Version $Version - $CLIName = $FullCLIName.Split('-')[0] +# Let the freshly downloaded bundle (re)create the `apify`/`actor` wrapper scripts (.cmd, .ps1 and a POSIX +# shim), so the shim content lives in one place rather than being duplicated here. +# Skip the bundle's automatic version check - we just downloaded the requested version. +$env:APIFY_CLI_SKIP_UPDATE_CHECK = "1" +& (Join-Path $InstallLocation "apify-cli.exe") install --shims-only - Download-File-To-Location -URL $URL -FileName $CLIName -Location $InstallLocation -Type 0 -Version $Version +# Clean up any legacy full bundles the wrappers replace, so the wrappers (not the `.exe`) resolve on PATH. +foreach ($Entrypoint in @("apify", "actor")) { + Remove-Item -Path (Join-Path $InstallLocation "${Entrypoint}.exe") -Force -ErrorAction Ignore + Remove-Item -Path (Join-Path $InstallLocation "${Entrypoint}.exe.old") -Force -ErrorAction Ignore } # Download the updated upgrade script (should rarely change but just in case) diff --git a/src/commands/cli-management/install.ts b/src/commands/cli-management/install.ts index 7069b967c..527a4ae45 100644 --- a/src/commands/cli-management/install.ts +++ b/src/commands/cli-management/install.ts @@ -3,12 +3,15 @@ import { existsSync, openSync } from 'node:fs'; import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +import process from 'node:process'; import { ReadStream } from 'node:tty'; import chalk from 'chalk'; import which from 'which'; +import { writeEntrypointShims } from '../../lib/bundleMigration.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; +import { Flags } from '../../lib/command-framework/flags.js'; import { useCLIMetadata } from '../../lib/hooks/useCLIMetadata.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, simpleLog, success, warning } from '../../lib/outputs.js'; @@ -25,6 +28,15 @@ export class InstallCommand extends ApifyCommand { static override hidden = true; + static override flags = { + 'shims-only': Flags.boolean({ + description: + 'Only (re)create the apify/actor wrapper scripts next to the bundle, then exit. Used by the install/upgrade scripts.', + hidden: true, + default: false, + }), + }; + async run() { const { installMethod, installPath, version } = useCLIMetadata(); @@ -35,6 +47,17 @@ export class InstallCommand extends ApifyCommand { assert(installPath, 'When CLI is installed via bundles, the install path must be set'); + // Always (re)create the wrapper scripts so `apify`/`actor` resolve from cmd.exe, PowerShell and POSIX + // shells. This is the single source of truth for shim content, shared by install, upgrade and migration. + writeEntrypointShims(installPath, process.platform === 'win32'); + + cliDebugPrint('[install] wrote entrypoint shims to', installPath); + + // The install/upgrade scripts invoke us purely to (re)write the shims; nothing else applies to them. + if (this.flags.shimsOnly) { + return; + } + const installMarkerPath = pathToInstallMarker(installPath); if (existsSync(installMarkerPath)) { diff --git a/src/commands/cli-management/upgrade.ts b/src/commands/cli-management/upgrade.ts index 7ade40737..e1c3e0d6d 100644 --- a/src/commands/cli-management/upgrade.ts +++ b/src/commands/cli-management/upgrade.ts @@ -1,12 +1,14 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { lstat, readdir, writeFile } from 'node:fs/promises'; +import { readdir, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import process from 'node:process'; import chalk from 'chalk'; import { gte } from 'semver'; import { USER_AGENT } from '../../entrypoints/_shared.js'; +import { writeEntrypointShims } from '../../lib/bundleMigration.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Flags } from '../../lib/command-framework/flags.js'; import { execWithLog } from '../../lib/exec.js'; @@ -34,7 +36,8 @@ const MINIMUM_VERSION_FOR_UPGRADE_COMMAND = '1.0.1'; * Unix-based systems do not require a similar script as they allow the executing process to override itself * (so we can replace the binary while it is running) */ -const WINDOWS_UPGRADE_SCRIPT_URL = 'https://raw.githubusercontent.com/apify/apify-cli/main/scripts/install/upgrade.ps1'; +const WINDOWS_UPGRADE_SCRIPT_URL = + 'https://raw.githubusercontent.com/apify/apify-cli/refs/heads/master/scripts/install/upgrade.ps1'; export class UpgradeCommand extends ApifyCommand { static override name = 'upgrade' as const; @@ -295,7 +298,8 @@ export class UpgradeCommand extends ApifyCommand { const metadata = useCLIMetadata(); for (const asset of assets) { - const cliName = asset.name.split('-')[0]; + // A single `apify-cli` bundle now powers both the `apify` and `actor` CLIs (via wrapper scripts). + const cliName = 'apify-cli'; const filePath = join(bundleDirectory, cliName); info({ message: `Downloading \`${cliName}\` binary of the Apify CLI...` }); @@ -330,21 +334,24 @@ export class UpgradeCommand extends ApifyCommand { info({ message: chalk.gray(`Writing ${cliName} to ${filePath}...`) }); const buffer = await res.arrayBuffer(); + const tmpPath = `${filePath}.${process.pid}.tmp`; try { - const originalFilePerms = await lstat(filePath) - .then((stat) => stat.mode) - // Default to rwx for current user and rx for group and others - .catch(() => 0o755); - - await writeFile(filePath, Buffer.from(buffer), { - // Make the file executable again on unix systems, by always making the current user have rwx - // eslint-disable-next-line no-bitwise -- intentionally using bitwise operators - mode: originalFilePerms | 0o700, - }); + // Write to a temp file and rename it into place. The running process is the `apify-cli` + // binary itself (invoked via the wrapper scripts), and overwriting it in place would fail + // with ETXTBSY on Linux. A rename swaps the directory entry, leaving the running inode intact. + await writeFile(tmpPath, Buffer.from(buffer), { mode: 0o755 }); + await rename(tmpPath, filePath); + + // (Re)create the apify/actor wrapper scripts, in case they are missing (e.g. an upgrade right + // after an interrupted migration). They are tiny and depend only on the apify-cli binary. + // This is the Unix upgrade path; Windows upgrades run `apify-cli install --shims-only` instead. + writeEntrypointShims(bundleDirectory, false); cliDebugPrint(`[upgrade ${cliName}] wrote asset to`, filePath); } catch (err: any) { + await rm(tmpPath, { force: true }).catch(() => {}); + cliDebugPrint('[upgrade] failed to write asset', { error: err }); error({ diff --git a/src/entrypoints/_shared.ts b/src/entrypoints/_shared.ts index 501469d7d..363801f71 100644 --- a/src/entrypoints/_shared.ts +++ b/src/entrypoints/_shared.ts @@ -1,3 +1,4 @@ +import { basename } from 'node:path'; import process from 'node:process'; import { parseArgs } from 'node:util'; @@ -5,6 +6,7 @@ import chalk from 'chalk'; import { satisfies } from 'semver'; import type { UpgradeCommand as TypeUpgradeCommand } from '../commands/cli-management/upgrade.js'; +import { migrateLegacyBundleInstallIfNeeded } from '../lib/bundleMigration.js'; import type { BuiltApifyCommand } from '../lib/command-framework/apify-command.js'; import { commandRegistry, internalRunCommand } from '../lib/command-framework/apify-command.js'; import { CommandError } from '../lib/command-framework/CommandError.js'; @@ -23,6 +25,31 @@ const cliMetadata = useCLIMetadata(); export const USER_AGENT = `Apify CLI/${cliMetadata.version} (https://github.com/apify/apify-cli)`; +/** + * Resolves which CLI command set ("apify" or "actor") the single bundle should expose. + * + * The wrapper scripts created during install/upgrade set `APIFY_CLI_ENTRYPOINT`. As a fallback (e.g. + * a legacy `apify`/`actor` bundle that has not been migrated to a wrapper script yet), we infer the + * entrypoint from the name the executable was invoked as. + */ +export function resolveEntrypoint(): 'apify' | 'actor' { + const fromEnv = process.env.APIFY_CLI_ENTRYPOINT?.toLowerCase(); + + if (fromEnv === 'actor') { + return 'actor'; + } + + if (fromEnv === 'apify') { + return 'apify'; + } + + const execName = basename(process.execPath) + .replace(/\.exe$/i, '') + .toLowerCase(); + + return execName === 'actor' ? 'actor' : 'apify'; +} + export function processVersionCheck(cliName: string) { if (cliMetadata.installMethod === 'bundle') { return; @@ -109,6 +136,10 @@ async function runVersionCheck(entrypoint: string, maybeCommandName?: string) { } export async function runCLI(entrypoint: string) { + // Clean up a legacy multi-bundle install (apify + actor + apify-cli) into the new single-bundle + // layout (apify-cli binary + apify/actor wrapper scripts) on the first run after an upgrade. + migrateLegacyBundleInstallIfNeeded(); + cliDebugPrint('CLIMetadata', { ...cliMetadata, fullVersionString: cliMetadata.fullVersionString, diff --git a/src/entrypoints/apify-cli.ts b/src/entrypoints/apify-cli.ts new file mode 100644 index 000000000..8d69db138 --- /dev/null +++ b/src/entrypoints/apify-cli.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { actorCommands, apifyCommands } from '../commands/_register.js'; +import { processVersionCheck, resolveEntrypoint, runCLI } from './_shared.js'; + +// A single bundle now powers both the `apify` and `actor` CLIs. The wrapper scripts created during +// install set `APIFY_CLI_ENTRYPOINT` to pick which command set to expose (see `resolveEntrypoint`). +const entrypoint = resolveEntrypoint(); + +processVersionCheck(entrypoint === 'apify' ? 'Apify' : 'Actor'); + +// Register the command set matching the resolved entrypoint +for (const CommandClass of entrypoint === 'apify' ? apifyCommands : actorCommands) { + CommandClass.registerCommand(entrypoint); +} + +await runCLI(entrypoint); diff --git a/src/lib/bundleMigration.ts b/src/lib/bundleMigration.ts new file mode 100644 index 000000000..862f7ba18 --- /dev/null +++ b/src/lib/bundleMigration.ts @@ -0,0 +1,238 @@ +import { chmodSync, copyFileSync, existsSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +import { useCLIMetadata } from './hooks/useCLIMetadata.js'; +import { cliDebugPrint } from './utils/cliDebugPrint.js'; + +const ENTRYPOINTS = ['apify', 'actor'] as const; + +/** + * POSIX `sh` wrapper that invokes the `apify-cli` bundle with the right entrypoint. Used directly on Unix, + * and by Git Bash / MSYS2 / Cygwin on Windows (those resolve the bare, extensionless name, not `.cmd`). + */ +export function unixWrapperScript(entrypoint: string) { + return [ + '#!/bin/sh', + 'DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"', + `APIFY_CLI_ENTRYPOINT=${entrypoint} exec "$DIR/apify-cli" "$@"`, + '', + ].join('\n'); +} + +/** + * Windows `.cmd` wrapper (for `cmd.exe`). `setlocal` keeps `APIFY_CLI_ENTRYPOINT` from leaking into the + * caller's shell; the implicit `endlocal` at end-of-file preserves the bundle's exit code. + */ +export function windowsCmdWrapperScript(entrypoint: string) { + return ['@echo off', 'setlocal', `set "APIFY_CLI_ENTRYPOINT=${entrypoint}"`, '"%~dp0apify-cli.exe" %*', ''].join( + '\r\n', + ); +} + +/** + * Windows PowerShell wrapper (`.ps1`). PowerShell can run the `.cmd` via PATHEXT, but a native `.ps1` + * avoids spawning `cmd.exe` and is what some tools look for. The env var is restored afterwards so it does + * not leak into the caller's session, and the bundle's exit code is propagated. + */ +export function windowsPowershellWrapperScript(entrypoint: string) { + return [ + '#!/usr/bin/env pwsh', + '$prev = $env:APIFY_CLI_ENTRYPOINT', + `$env:APIFY_CLI_ENTRYPOINT = "${entrypoint}"`, + 'try {', + ' & "$PSScriptRoot\\apify-cli.exe" @args', + '} finally {', + ' $env:APIFY_CLI_ENTRYPOINT = $prev', + '}', + 'exit $LASTEXITCODE', + '', + ].join('\r\n'); +} + +/** + * Writes `data` to `targetPath` atomically: it writes to a temp file in the same directory and then renames + * it into place. The rename swaps the directory entry without touching the inode that may be backing a + * running process, so this is safe even when `targetPath` is a binary that is currently executing (writing + * to it directly would fail with `ETXTBSY` on Linux). + */ +function atomicWriteSync(targetPath: string, data: string | Buffer, mode?: number) { + const tmpPath = `${targetPath}.${process.pid}.tmp`; + + try { + writeFileSync(tmpPath, data); + + if (mode !== undefined) { + chmodSync(tmpPath, mode); + } + + renameSync(tmpPath, targetPath); + } catch (err) { + try { + rmSync(tmpPath, { force: true }); + } catch { + // best-effort cleanup + } + + throw err; + } +} + +/** + * Copies `srcPath` to `targetPath` atomically (temp file + rename), so an in-place overwrite never truncates + * a running executable. See {@link atomicWriteSync}. + */ +function atomicCopySync(srcPath: string, targetPath: string, mode?: number) { + const tmpPath = `${targetPath}.${process.pid}.tmp`; + + try { + copyFileSync(srcPath, tmpPath); + + if (mode !== undefined) { + chmodSync(tmpPath, mode); + } + + renameSync(tmpPath, targetPath); + } catch (err) { + try { + rmSync(tmpPath, { force: true }); + } catch { + // best-effort cleanup + } + + throw err; + } +} + +/** + * (Re)creates the `apify` and `actor` wrapper scripts in `binDir`, pointing at the `apify-cli` bundle. + * + * Always writes the extensionless POSIX `sh` shim; on Windows it additionally writes a `.cmd` (for cmd.exe) + * and a `.ps1` (for PowerShell) so the command resolves from every shell. Each file is written atomically + * and independently, so one failure does not prevent the others. + */ +export function writeEntrypointShims(binDir: string, isWindows: boolean) { + for (const entrypoint of ENTRYPOINTS) { + atomicWriteSync(join(binDir, entrypoint), unixWrapperScript(entrypoint), 0o755); + + if (isWindows) { + atomicWriteSync(join(binDir, `${entrypoint}.cmd`), windowsCmdWrapperScript(entrypoint)); + atomicWriteSync(join(binDir, `${entrypoint}.ps1`), windowsPowershellWrapperScript(entrypoint)); + } + } +} + +/** + * Older CLI installs shipped two full bundles (`apify` and `actor`) plus an `apify-cli` copy, dropping the + * same binary three times into the install directory. The CLI now ships a single `apify-cli` bundle, with + * `apify` and `actor` being tiny wrapper scripts that set `APIFY_CLI_ENTRYPOINT`. + * + * When a user upgrades from an old install, the upgrade overwrites the legacy `apify`/`actor` bundles with + * the new single bundle. On the first run afterwards this migrates the directory into the new layout: + * the running bundle is copied to `apify-cli` and the `apify`/`actor` bundles are replaced by wrapper scripts. + * + * Every step is best-effort, atomic, and isolated: if anything fails, the running process keeps working and + * the migration is retried (and completed) on the next run. + */ +export function migrateLegacyBundleInstallIfNeeded() { + const metadata = useCLIMetadata(); + + // Only on-disk bundle installs have this layout to migrate + if (metadata.installMethod !== 'bundle') { + return; + } + + const isWindows = metadata.platform === 'windows'; + const binDir = dirname(process.execPath); + const selfName = basename(process.execPath) + .replace(/\.exe$/i, '') + .toLowerCase(); + + // Best-effort cleanup of binaries renamed by a previous migration run. On Windows we cannot delete the + // currently running executable, only rename it, so the leftover `.old` file is removed on a later run. + if (isWindows) { + for (const entrypoint of ENTRYPOINTS) { + const stalePath = join(binDir, `${entrypoint}.exe.old`); + + if (existsSync(stalePath)) { + try { + rmSync(stalePath, { force: true }); + cliDebugPrint('[migration] removed stale legacy binary', stalePath); + } catch (err) { + cliDebugPrint('[migration] failed to remove stale legacy binary', { stalePath, err }); + } + } + } + } + + // Already running as the single `apify-cli` bundle - the install is migrated + if (selfName === 'apify-cli') { + return; + } + + // We only know how to migrate the legacy `apify`/`actor` full bundles + if (!(ENTRYPOINTS as readonly string[]).includes(selfName)) { + return; + } + + cliDebugPrint('[migration] legacy multi-bundle install detected, migrating to single bundle', { + binDir, + selfName, + }); + + const ext = isWindows ? '.exe' : ''; + const apifyCliPath = join(binDir, `apify-cli${ext}`); + + // 1. Copy the bundle we are currently running as into the canonical `apify-cli` binary. If this fails + // we bail out without touching the wrappers (they would otherwise point at a stale/missing binary); + // the running bundle is untouched, so the migration is retried on the next run. + try { + atomicCopySync(process.execPath, apifyCliPath, isWindows ? undefined : 0o755); + + cliDebugPrint('[migration] copied current bundle to', apifyCliPath); + } catch (err) { + cliDebugPrint('[migration] failed to copy current bundle, will retry on next run', { err }); + return; + } + + // 2. Replace the `apify` and `actor` bundles with wrapper scripts. On Unix this atomically overwrites the + // running bundle (a rename swaps the inode, avoiding ETXTBSY); on Windows the wrappers are separate + // `.cmd`/`.ps1`/sh files alongside the legacy `.exe`, which is cleaned up below. + try { + writeEntrypointShims(binDir, isWindows); + cliDebugPrint('[migration] wrote entrypoint shims'); + } catch (err) { + cliDebugPrint('[migration] failed to write entrypoint shims, will retry on next run', { err }); + } + + // 3. On Windows, remove the legacy full bundles so the wrappers (not the `.exe`) resolve on PATH. Each is + // isolated; the currently-running `.exe` cannot be deleted, only renamed (cleaned up on a later run). + if (isWindows) { + for (const entrypoint of ENTRYPOINTS) { + const legacyBinaryPath = join(binDir, `${entrypoint}.exe`); + + if (!existsSync(legacyBinaryPath)) { + continue; + } + + try { + if (resolve(legacyBinaryPath) === resolve(process.execPath)) { + renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); + cliDebugPrint('[migration] renamed running legacy binary', legacyBinaryPath); + } else { + rmSync(legacyBinaryPath, { force: true }); + } + } catch (err) { + try { + renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); + } catch { + // leave it; a later run will retry + } + + cliDebugPrint('[migration] failed to remove legacy binary', { legacyBinaryPath, err }); + } + } + } + + cliDebugPrint('[migration] migration to single bundle complete'); +} diff --git a/src/lib/hooks/useCLIVersionAssets.ts b/src/lib/hooks/useCLIVersionAssets.ts index 85482142b..dfd16491b 100644 --- a/src/lib/hooks/useCLIVersionAssets.ts +++ b/src/lib/hooks/useCLIVersionAssets.ts @@ -29,9 +29,9 @@ function isInstalledOnBaseline() { } if (metadata.platform === 'windows') { - // Windows ARM64 always needs baseline + // Windows ARM64 has a native bundle, which is never a baseline build if (metadata.arch === 'arm64') { - return true; + return false; } // Get AVX2 support like the install script does @@ -91,7 +91,14 @@ export async function useCLIVersionAssets(version: string) { const requiresBaseline = isInstalledOnBaseline(); const assets = body.assets.filter((asset) => { - const [_cliEntrypoint, _version, assetOs, assetArch, assetBaselineOrMusl, assetBaseline] = asset.name + // We now ship a single `apify-cli` bundle. The legacy `apify-*`/`actor-*` assets are kept only as a + // backwards-compatible backup for old installs and must be ignored by the current upgrade flow. + if (!asset.name.startsWith('apify-cli-')) { + return false; + } + + const [_version, assetOs, assetArch, assetBaselineOrMusl, assetBaseline] = asset.name + .slice('apify-cli-'.length) .replace(versionWithoutV, 'version') .replace('.exe', '') .split('-');