From 6884cd95e693bdfd33a879f77a269ebd57774511 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 29 May 2026 16:47:26 +0300 Subject: [PATCH 1/6] feat: ship a single apify-cli bundle with apify/actor wrapper scripts The CLI previously shipped three full bundles (apify, actor, apify-cli), dropping the same ~70MB binary three times into the install directory. Now we build one apify-cli bundle; apify and actor are tiny wrapper scripts that invoke it with APIFY_CLI_ENTRYPOINT set, which the entrypoint uses to pick the command set. - build-cli-bundles: build only apify-cli; publish apify-*/actor-* copies as backwards-compatible backups for the old upgrade flow - entrypoints: new apify-cli entrypoint + resolveEntrypoint() - install.sh / install.ps1 / dev-test-install.sh: drop the apify-cli binary and generate apify/actor (.cmd on Windows) wrapper scripts - upgrade: download the single apify-cli bundle; write it and the wrappers atomically (temp + rename) to avoid ETXTBSY on a running binary (Linux) - bundleMigration: on first run after upgrading from a legacy 3-bundle install, copy the running bundle to apify-cli and replace apify/actor with wrappers (rename-not-delete on Windows for the running exe) - useCLIVersionAssets: match only apify-cli-* assets, ignoring backups - add dev-test-install-legacy.sh to reproduce the old layout for testing --- scripts/build-cli-bundles.ts | 27 ++- scripts/install/dev-test-install-legacy.sh | 145 +++++++++++++++ scripts/install/dev-test-install.sh | 24 ++- scripts/install/install.ps1 | 106 ++++++----- scripts/install/install.sh | 42 ++--- scripts/install/upgrade.ps1 | 24 ++- src/commands/cli-management/upgrade.ts | 32 ++-- src/entrypoints/_shared.ts | 31 ++++ src/entrypoints/apify-cli.ts | 16 ++ src/lib/bundleMigration.ts | 201 +++++++++++++++++++++ src/lib/hooks/useCLIVersionAssets.ts | 9 +- 11 files changed, 557 insertions(+), 100 deletions(-) create mode 100644 scripts/install/dev-test-install-legacy.sh create mode 100644 src/entrypoints/apify-cli.ts create mode 100644 src/lib/bundleMigration.ts diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 5ae25d03d..95fcc920e 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -8,7 +8,7 @@ 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'; @@ -57,12 +57,17 @@ const targets = (() => { ] 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 @@ -135,7 +140,8 @@ for (const entryPoint of entryPoints) { } } - const fileName = `${cliName}-${version}-${os}-${arch}${musl ? '-musl' : ''}${baseline ? '-baseline' : ''}`; + const versionSuffix = `${version}-${os}-${arch}${musl ? '-musl' : ''}${baseline ? '-baseline' : ''}`; + const fileName = `${cliName}-${versionSuffix}`; const outFile = fileURLToPath(new URL(`../bundles/${fileName}`, import.meta.url)); @@ -156,6 +162,19 @@ for (const entryPoint of entryPoints) { bytecode: true, }); + // 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); + } + // Remove the arch override await writeFile(metadataFile, newContent); } 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..e93453510 100644 --- a/scripts/install/dev-test-install.sh +++ b/scripts/install/dev-test-install.sh @@ -109,17 +109,27 @@ 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}" +cp "bundles/apify-cli-$version-$target" "$bin_dir/apify-cli" +chmod +x "$bin_dir/apify-cli" - info "Installing $executable_name bundle for version $version and target $target" +# Create the `apify` and `actor` wrapper scripts +for entrypoint in apify actor; do + script_path="$bin_dir/$entrypoint" - cp "bundles/$executable_name-$version-$target" "$bin_dir/$output_filename" - chmod +x "$bin_dir/$output_filename" + cat >"$script_path" <"$script_path" < { 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,23 @@ 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. + writeUnixWrapperScripts(bundleDirectory); 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..7ca4a4361 --- /dev/null +++ b/src/lib/bundleMigration.ts @@ -0,0 +1,201 @@ +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; + +/** + * Content of the Unix wrapper script that invokes the `apify-cli` bundle with the right entrypoint. + */ +export function unixWrapperScript(entrypoint: string) { + return [ + '#!/bin/sh', + 'DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"', + `APIFY_CLI_ENTRYPOINT=${entrypoint} exec "$DIR/apify-cli" "$@"`, + '', + ].join('\n'); +} + +/** + * Content of the Windows wrapper script (`.cmd`) that invokes the `apify-cli` bundle with the right entrypoint. + */ +export function windowsWrapperScript(entrypoint: string) { + return ['@echo off', `set "APIFY_CLI_ENTRYPOINT=${entrypoint}"`, '"%~dp0apify-cli.exe" %*', ''].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` Unix wrapper scripts in `binDir`, pointing at the `apify-cli` bundle. + * Each script is written atomically and independently, so one failure does not prevent the other. + */ +export function writeUnixWrapperScripts(binDir: string) { + for (const entrypoint of ENTRYPOINTS) { + atomicWriteSync(join(binDir, entrypoint), unixWrapperScript(entrypoint), 0o755); + } +} + +/** + * 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 small wrapper scripts. Each entrypoint is handled in + // isolation so a failure on one does not abort the other. + for (const entrypoint of ENTRYPOINTS) { + try { + if (isWindows) { + atomicWriteSync(join(binDir, `${entrypoint}.cmd`), windowsWrapperScript(entrypoint)); + + const legacyBinaryPath = join(binDir, `${entrypoint}.exe`); + + if (existsSync(legacyBinaryPath)) { + if (resolve(legacyBinaryPath) === resolve(process.execPath)) { + // Windows forbids deleting a running executable but allows renaming it. + renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); + cliDebugPrint('[migration] renamed running legacy binary', legacyBinaryPath); + } else { + try { + rmSync(legacyBinaryPath, { force: true }); + } catch { + renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); + } + } + } + } else { + // Write the wrapper atomically (temp file + rename) so we never truncate the running + // executable in place, which would fail with ETXTBSY on Linux. + atomicWriteSync(join(binDir, entrypoint), unixWrapperScript(entrypoint), 0o755); + } + + cliDebugPrint('[migration] installed wrapper for', entrypoint); + } catch (err) { + cliDebugPrint('[migration] failed to install wrapper, will retry on next run', { entrypoint, err }); + } + } + + cliDebugPrint('[migration] migration to single bundle complete'); +} diff --git a/src/lib/hooks/useCLIVersionAssets.ts b/src/lib/hooks/useCLIVersionAssets.ts index 85482142b..2f662e86d 100644 --- a/src/lib/hooks/useCLIVersionAssets.ts +++ b/src/lib/hooks/useCLIVersionAssets.ts @@ -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('-'); From 70607d50af6a21c16a5dc576d285dc81ab42a8ce Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 29 May 2026 17:05:49 +0300 Subject: [PATCH 2/6] feat: native Windows ARM64 bundles (adapted from #1057) Bun now ships native Windows ARM64 builds, so drop the hack that compiled an x64 bundle and relabelled it as arm64. - build-cli-bundles: build a native bun-windows-arm64 target (full list and the win32 branch); remove the pwsh SystemType detection + APIFY_BUNDLE_ARCH override and the per-target metadata reset - install.ps1: detect the arch from PROCESSOR_ARCHITECTURE; only x64 has baseline (non-AVX2) builds, so ARM64 never downloads a -baseline bundle - install.sh: map MINGW64 ARM64 to the windows-arm64 target - useCLIVersionAssets: Windows ARM64 has a native (non-baseline) bundle, so stop requiring -baseline assets there (otherwise upgrade finds no asset) - CI (check/pre_release/release): build on windows-11-arm with native bun via setup-bun (drop the manual install workaround) and a bun-target-arch matrix Unlike #1057, asset names keep the arm64 suffix (matching the build output, useCLIMetadata's process.arch, and the asset matcher) rather than renaming to aarch64, which in #1057 mismatches the produced -arm64 artifacts. --- .github/workflows/check.yaml | 20 ++++--------------- .github/workflows/pre_release.yaml | 20 ++++--------------- .github/workflows/release.yaml | 20 ++++--------------- scripts/build-cli-bundles.ts | 29 ++++++---------------------- scripts/install/install.ps1 | 28 +++++++++++++-------------- scripts/install/install.sh | 3 +++ src/lib/hooks/useCLIVersionAssets.ts | 4 ++-- 7 files changed, 37 insertions(+), 87 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 0e50a89e3..bf6a5b804 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,8 @@ 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 + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-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..fe003d49b 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,8 @@ 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 + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-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..df68b3266 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,8 @@ 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 + bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-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 95fcc920e..5b5d59622 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs'; 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,6 +21,7 @@ const targets = (() => { // 'bun-windows-x64', 'bun-windows-x64-baseline', + 'bun-windows-arm64', 'bun-linux-x64', 'bun-linux-x64-baseline', 'bun-linux-arm64', @@ -31,13 +32,16 @@ const targets = (() => { '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, ] 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[]; } @@ -122,24 +126,6 @@ for (const entryPoint of entryPoints) { 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'; - - // On arm, process.arch will still return x64, which will break the upgrade command. - // So we override the arch to arm64 - - const newNewContent = newContent.replace('process.env.APIFY_BUNDLE_ARCH', '"arm64"'); - - await writeFile(metadataFile, newNewContent); - } - } - const versionSuffix = `${version}-${os}-${arch}${musl ? '-musl' : ''}${baseline ? '-baseline' : ''}`; const fileName = `${cliName}-${versionSuffix}`; @@ -174,9 +160,6 @@ for (const entryPoint of entryPoints) { await copyFile(compiledFile, backupFile); } - - // Remove the arch override - await writeFile(metadataFile, newContent); } } diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 124072d68..c1bb0d298 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,13 +108,19 @@ 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" } diff --git a/scripts/install/install.sh b/scripts/install/install.sh index adc534ed0..7907b96c1 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 ;; diff --git a/src/lib/hooks/useCLIVersionAssets.ts b/src/lib/hooks/useCLIVersionAssets.ts index 2f662e86d..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 From 4db6ced1d0dc5859ba15f35751a36e9091af22f6 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 29 May 2026 17:12:29 +0300 Subject: [PATCH 3/6] refactor(build): drop the 'as never' bun target casts Per Bun's supported-targets matrix (https://bun.com/docs/bundler/executables#supported-targets), baseline/modern (SIMD) variants only exist for x64, and @types/bun's CompileTarget union only accepts the SIMD-then-libc order (`bun-linux-x64-baseline-musl`). - reorder `bun-linux-x64-musl-baseline` -> `bun-linux-x64-baseline-musl` so it matches the type (no cast needed); bun accepts either order at build time - drop `bun-linux-arm64-musl-baseline`: arm64 has no baseline variant, so it was a meaningless duplicate of the arm64 musl build and nothing ever requested it - parse the target's trailing modifiers order-independently and still emit the asset suffix in the canonical `-musl-baseline` order the install/upgrade matchers expect, so every published asset name is unchanged --- scripts/build-cli-bundles.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 5b5d59622..f75326b90 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -32,8 +32,7 @@ const targets = (() => { '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[]; } @@ -56,8 +55,7 @@ const targets = (() => { '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[]; })(); @@ -118,15 +116,15 @@ 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('-'); + // `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('-'); - if (musl === 'baseline') { - musl = ''; - baseline = 'baseline'; - } + const isMusl = modifiers.includes('musl'); + const isBaseline = modifiers.includes('baseline'); - const versionSuffix = `${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)); From 3d06e98e8fc603c8f6b9b2e17a62b2b2651229b5 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 29 May 2026 17:16:06 +0300 Subject: [PATCH 4/6] chore(build): stop building redundant arm64 baseline bundles Baseline (non-AVX2) builds are an x64-only concept, so bun-darwin-arm64-baseline and bun-linux-arm64-baseline just duplicated their non-baseline arm64 builds. The install/upgrade flow never requests them (baseline is only ever appended for x64), and GitHub download counts confirm no real usage: across all releases the arm64 baseline assets sit at the scraper-noise floor (~205 total, max 3/release) versus darwin-arm64 at 1557 (max 260) and linux-arm64 at 262 (max 15). --- scripts/build-cli-bundles.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index f75326b90..848db6179 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -25,11 +25,9 @@ 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-baseline-musl', @@ -48,11 +46,9 @@ 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-baseline-musl', From c80db3efa7dd119d17f47638d54be0dff77ddada Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Sat, 30 May 2026 23:15:17 +0300 Subject: [PATCH 5/6] ci: don't build arm64-baseline on windows on arm Cuz it doesn't exist anyways Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/check.yaml | 4 +++- .github/workflows/pre_release.yaml | 4 +++- .github/workflows/release.yaml | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index bf6a5b804..48f27a292 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -241,7 +241,9 @@ jobs: cd C:\test bun init -y bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts - bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-baseline --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 fe003d49b..43f582e87 100644 --- a/.github/workflows/pre_release.yaml +++ b/.github/workflows/pre_release.yaml @@ -138,7 +138,9 @@ jobs: cd C:\test bun init -y bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts - bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-baseline --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 df68b3266..4e7a4ee3f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -166,7 +166,9 @@ jobs: cd C:\test bun init -y bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }} --outfile test index.ts - bun build --compile --target=bun-windows-${{ matrix.bun-target-arch }}-baseline --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 From cfa9e183868f6aa8223c1947f5f6d5ed4e40ab04 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Sat, 30 May 2026 23:41:39 +0300 Subject: [PATCH 6/6] refactor(install): centralize wrapper-shim creation in the bundle Make the bundle the single source of truth for the apify/actor wrapper scripts, and broaden Windows shell coverage (inspired by npm's cmd-shim, which we don't need: it locates a node interpreter for script bins, whereas we exec a native binary with an env var). - install command gains a hidden --shims-only flag and now (re)writes the wrapper scripts via writeEntrypointShims(); install.sh/install.ps1/upgrade.ps1 invoke the bundle instead of hand-rolling the shim text, so it lives in one place (TS) rather than being duplicated across sh + ps1 + ts - writeEntrypointShims now writes, on Windows, a .cmd (with setlocal so APIFY_CLI_ENTRYPOINT no longer leaks into the caller's shell), a native .ps1, and an extensionless POSIX sh shim so the command also resolves from Git Bash / MSYS2 / Cygwin (previously only .cmd existed -> bare `apify` failed there) - migration reuses writeEntrypointShims and isolates the Windows legacy-.exe cleanup as its own step - install/upgrade scripts skip the automatic version check when only creating shims (local, offline operation; we just downloaded the requested version) --- scripts/install/dev-test-install.sh | 19 +---- scripts/install/install.ps1 | 23 ++++-- scripts/install/install.sh | 20 +---- scripts/install/upgrade.ps1 | 17 ++-- src/commands/cli-management/install.ts | 23 ++++++ src/commands/cli-management/upgrade.ts | 5 +- src/lib/bundleMigration.ts | 109 +++++++++++++++++-------- 7 files changed, 126 insertions(+), 90 deletions(-) diff --git a/scripts/install/dev-test-install.sh b/scripts/install/dev-test-install.sh index e93453510..edef975a0 100644 --- a/scripts/install/dev-test-install.sh +++ b/scripts/install/dev-test-install.sh @@ -118,22 +118,9 @@ info "Installing apify-cli bundle for version $version and target $target" cp "bundles/apify-cli-$version-$target" "$bin_dir/apify-cli" chmod +x "$bin_dir/apify-cli" -# Create the `apify` and `actor` wrapper scripts -for entrypoint in apify actor; do - script_path="$bin_dir/$entrypoint" - - cat >"$script_path" <"$script_path" < { 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 3e8c283b3..e1c3e0d6d 100644 --- a/src/commands/cli-management/upgrade.ts +++ b/src/commands/cli-management/upgrade.ts @@ -8,7 +8,7 @@ import chalk from 'chalk'; import { gte } from 'semver'; import { USER_AGENT } from '../../entrypoints/_shared.js'; -import { writeUnixWrapperScripts } from '../../lib/bundleMigration.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'; @@ -345,7 +345,8 @@ export class UpgradeCommand extends ApifyCommand { // (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. - writeUnixWrapperScripts(bundleDirectory); + // 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) { diff --git a/src/lib/bundleMigration.ts b/src/lib/bundleMigration.ts index 7ca4a4361..862f7ba18 100644 --- a/src/lib/bundleMigration.ts +++ b/src/lib/bundleMigration.ts @@ -8,7 +8,8 @@ import { cliDebugPrint } from './utils/cliDebugPrint.js'; const ENTRYPOINTS = ['apify', 'actor'] as const; /** - * Content of the Unix wrapper script that invokes the `apify-cli` bundle with the right entrypoint. + * 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 [ @@ -20,10 +21,33 @@ export function unixWrapperScript(entrypoint: string) { } /** - * Content of the Windows wrapper script (`.cmd`) that invokes the `apify-cli` bundle with the right entrypoint. + * 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 windowsWrapperScript(entrypoint: string) { - return ['@echo off', `set "APIFY_CLI_ENTRYPOINT=${entrypoint}"`, '"%~dp0apify-cli.exe" %*', ''].join('\r\n'); +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'); } /** @@ -81,12 +105,20 @@ function atomicCopySync(srcPath: string, targetPath: string, mode?: number) { } /** - * (Re)creates the `apify` and `actor` Unix wrapper scripts in `binDir`, pointing at the `apify-cli` bundle. - * Each script is written atomically and independently, so one failure does not prevent the other. + * (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 writeUnixWrapperScripts(binDir: string) { +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)); + } } } @@ -163,37 +195,42 @@ export function migrateLegacyBundleInstallIfNeeded() { return; } - // 2. Replace the `apify` and `actor` bundles with small wrapper scripts. Each entrypoint is handled in - // isolation so a failure on one does not abort the other. - for (const entrypoint of ENTRYPOINTS) { - try { - if (isWindows) { - atomicWriteSync(join(binDir, `${entrypoint}.cmd`), windowsWrapperScript(entrypoint)); - - const legacyBinaryPath = join(binDir, `${entrypoint}.exe`); - - if (existsSync(legacyBinaryPath)) { - if (resolve(legacyBinaryPath) === resolve(process.execPath)) { - // Windows forbids deleting a running executable but allows renaming it. - renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); - cliDebugPrint('[migration] renamed running legacy binary', legacyBinaryPath); - } else { - try { - rmSync(legacyBinaryPath, { force: true }); - } catch { - renameSync(legacyBinaryPath, `${legacyBinaryPath}.old`); - } - } - } - } else { - // Write the wrapper atomically (temp file + rename) so we never truncate the running - // executable in place, which would fail with ETXTBSY on Linux. - atomicWriteSync(join(binDir, entrypoint), unixWrapperScript(entrypoint), 0o755); + // 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; } - cliDebugPrint('[migration] installed wrapper for', entrypoint); - } catch (err) { - cliDebugPrint('[migration] failed to install wrapper, will retry on next run', { entrypoint, err }); + 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 }); + } } }