diff --git a/.buildkite/commands/build-for-windows.ps1 b/.buildkite/commands/build-for-windows.ps1 index 0d8847cf14..66e8b607ac 100644 --- a/.buildkite/commands/build-for-windows.ps1 +++ b/.buildkite/commands/build-for-windows.ps1 @@ -71,3 +71,7 @@ If ($LastExitCode -ne 0) { Exit $LastExitCode } Write-Host "--- :package: Building AppX package" node scripts/package-appx.mjs If ($LastExitCode -ne 0) { Exit $LastExitCode } + +Write-Host "--- :package: Building standalone CLI bundle" +npm run cli:bundle -- win32 $Architecture +If ($LastExitCode -ne 0) { Exit $LastExitCode } diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 38576b1048..87c421107b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -214,10 +214,15 @@ steps: echo "--- 📃 Notarizing Binary" bundle exec fastlane notarize_binary + + echo "--- :package: Building standalone CLI bundle" + npm run cli:bundle -- darwin {{matrix}} plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] artifact_paths: - apps/studio/out/**/*.app.zip - apps/studio/out/*.dmg + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 matrix: - x64 - arm64 @@ -243,6 +248,8 @@ steps: - apps\studio\out\**\studio-update.nupkg - apps\studio\out\**\RELEASES - apps\studio\out\**\*.appx + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] matrix: - x64 @@ -283,8 +290,13 @@ steps: echo "--- :node: Building DEB" npm run make:linux-{{matrix}} + + echo "--- :package: Building standalone CLI bundle" + npm run cli:bundle -- linux {{matrix}} artifact_paths: - apps/studio/out/make/deb/**/*.deb + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 plugins: # Amazon Linux on the `default` queue lacks `dpkg`/`fakeroot` # required by electron-forge's MakerDeb, so we build inside a @@ -309,6 +321,7 @@ steps: buildkite-agent artifact download "*.nupkg" . buildkite-agent artifact download "*\\RELEASES" . buildkite-agent artifact download "*.deb" . + buildkite-agent artifact download "standalone-bundles/*" . .buildkite/commands/install-node-dependencies.sh diff --git a/.buildkite/release-build-and-distribute.yml b/.buildkite/release-build-and-distribute.yml index 742c55b4f3..d82b249dfa 100644 --- a/.buildkite/release-build-and-distribute.yml +++ b/.buildkite/release-build-and-distribute.yml @@ -51,10 +51,15 @@ steps: echo "--- 📃 Notarizing Binary" bundle exec fastlane notarize_binary + + echo "--- :package: Building standalone CLI bundle" + npm run cli:bundle -- darwin {{matrix}} plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] artifact_paths: - apps/studio/out/**/*.app.zip - apps/studio/out/*.dmg + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 matrix: - x64 - arm64 @@ -78,6 +83,8 @@ steps: - apps\studio\out\**\studio-update.nupkg - apps\studio\out\**\RELEASES - apps\studio\out\**\*.appx + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] matrix: - x64 @@ -109,8 +116,13 @@ steps: echo "--- :node: Building DEB" npm run make:linux-{{matrix}} + + echo "--- :package: Building standalone CLI bundle" + npm run cli:bundle -- linux {{matrix}} artifact_paths: - apps/studio/out/make/deb/**/*.deb + - standalone-bundles/studio-cli-*.tgz + - standalone-bundles/studio-cli-*.tgz.sha256 plugins: # Same Docker-on-default-queue pattern as the dev Linux build group # (see .buildkite/pipeline.yml): Amazon Linux on `default` lacks @@ -141,6 +153,7 @@ steps: buildkite-agent artifact download "*.nupkg" . buildkite-agent artifact download "*.deb" . buildkite-agent artifact download "*\\RELEASES" . + buildkite-agent artifact download "standalone-bundles/*" . .buildkite/commands/install-node-dependencies.sh diff --git a/docs/release-process.md b/docs/release-process.md index 0ea9f79e24..af6a479412 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -105,6 +105,37 @@ ruby fastlane/test/studio_release_version_test.rb ruby fastlane/test/studio_release_git_test.rb ``` +## Standalone Studio CLI Bundles (curl installer) + +Alongside the desktop app, every Studio build also publishes the standalone Studio CLI bundles — the `.tgz` (gzipped tar) archives the `install.sh` / `install.ps1` curl installers download — to the Apps CDN under the **`WordPress.com Studio CLI`** product. This is part of the normal build/distribute flow, so it happens for nightly/dev, beta, and stable builds with no separate step to run: + +- Each platform's build group builds its own bundle (`npm run cli:bundle `) on its own runner, producing `standalone-bundles/studio-cli--.tgz` + a `.sha256` sidecar for all six platform/arch targets. +- `distribute_builds` uploads the bundles with the **same version, build type, and visibility as the app build**, so the CLI tracks the app exactly: External for nightly/beta, and Internal-then-flipped-public for stable (their CDN post IDs ride the draft GitHub release and are flipped to External by `publish_release`, just like the app builds). + +### How the installers find a bundle + +`install.sh` / `install.ps1` download straight from the Apps CDN's versionless **`latest`** alias (a `302` to the newest published bundle), so they need no redirect layer: + +``` +https://appscdn.wordpress.com/downloads/wordpress-com-studio-cli//latest/full-install +``` + +`` is the CDN platform slug (`mac-silicon`, `mac-intel`, `windows-x64`, `windows-arm64`, `linux-x64`, `linux-arm64`). Two env vars adjust this: + +- `STUDIO_CLI_VERSION` — install a specific version instead of `latest` (e.g. `v1.11.0`). +- `STUDIO_CLI_URL` — bypass the CDN entirely and fetch `studio-cli--.tgz` (+ `.sha256` sidecar) from a base URL or local dir. Used for local testing and mirrors; this path verifies the checksum, whereas the CDN path relies on HTTPS plus a staged-extraction guard (the CDN exposes the SHA-256 only as build metadata, not a downloadable sidecar). + +Only the `install.sh` / `install.ps1` scripts themselves still need public hosting — the `curl … | bash` / `irm … | iex` entry points (e.g. `wp.build/install.sh`); the bundles come directly from the CDN. + +To (re)publish CLI bundles without a full app release — e.g. backfilling — build them and run the standalone lane: + +```sh +npm run cli:bundle -- darwin arm64 # repeat per platform/arch (each must be built on its own OS) +DRY_RUN=true bundle exec fastlane publish_studio_cli_binaries version:"v1.11.0" +``` + +Drop `DRY_RUN=true` to upload for real. The `version:` must match the app release's `v`-prefixed version so the bundles share its CDN path (it defaults to the current `package.json` version, `v`-prefixed). `visibility:` (default `external`) and `build_type:` (default `Production`) can be overridden. + ## Running Lanes Locally Lanes can be run locally for testing. Common requirements are Ruby and Bundler. Additional credentials depend on the lane: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 194e1c6b90..bedc935515 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -20,6 +20,8 @@ PROJECT_ROOT_FOLDER = File.dirname(File.expand_path(__dir__)) SECRETS_FOLDER = File.join(Dir.home, '.configure', 'studio', 'secrets') BUILDS_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'apps', 'studio', 'out') PHP_CLI_BUILDS_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'out', 'php-binaries') +# Standalone Studio CLI bundles (studio-cli--.tgz) from `npm run cli:bundle` +STANDALONE_BUNDLES_FOLDER = File.join(PROJECT_ROOT_FOLDER, 'standalone-bundles') # Enable dry run mode through environment variable DRY_RUN = ENV['DRY_RUN'] == 'true' @@ -43,6 +45,7 @@ APPLE_API_KEY_PATH = File.join(SECRETS_FOLDER, 'app_store_connect_fastlane_api_k # Site ID for WordPress.com Studio in the Apps CDN WPCOM_STUDIO_SITE_ID = '239164481' WPCOM_STUDIO_PHP_CLI_PRODUCT = 'WordPress.com Studio PHP CLI' +WPCOM_STUDIO_CLI_PRODUCT = 'WordPress.com Studio CLI' GITHUB_REPO = 'Automattic/studio' MAIN_BRANCH = 'trunk' @@ -300,6 +303,19 @@ lane :publish_php_cli_binaries do |version:, artifacts_dir: PHP_CLI_BUILDS_FOLDE ) end +desc 'Upload or update Studio CLI standalone bundles on Apps CDN' +# version defaults to the app's `v`-prefixed form so a manual publish lands at the same CDN +# path the release flow uses (distribute_builds passes the same v-prefixed version). +lane :publish_studio_cli_binaries do |version: "v#{read_package_json_version}", artifacts_dir: STANDALONE_BUNDLES_FOLDER, build_type: 'Production', visibility: 'external', error_on_duplicate: false| + upload_studio_cli_binaries_to_apps_cdn( + version:, + artifacts_dir:, + build_type:, + visibility:, + error_on_duplicate: error_on_duplicate.to_s != 'false' + ) +end + ######################################################################## # Release Management Lanes ######################################################################## @@ -751,6 +767,24 @@ def distribute_builds( build[:post_id] = result[:post_id] end + # Upload the standalone Studio CLI bundles alongside the app so the curl installers + # track every Studio build (nightly/dev, beta, stable). Merging them into + # builds_to_upload routes their post IDs through create_draft_github_release, so + # publish_release flips them to External exactly like the app builds. Skipped when no + # bundles are present (e.g. a local app-only distribute run that didn't build them). + if Dir.exist?(STANDALONE_BUNDLES_FOLDER) + cli_builds = upload_studio_cli_binaries_to_apps_cdn( + version: version, + artifacts_dir: STANDALONE_BUNDLES_FOLDER, + build_type: build_type, + visibility: visibility, + error_on_duplicate: false + ) + builds_to_upload = builds_to_upload.merge(cli_builds) + else + UI.important("No standalone CLI bundles in #{STANDALONE_BUNDLES_FOLDER}; skipping Studio CLI CDN upload.") + end + unless DRY_RUN buildkite_annotate( context: 'cdn-link', @@ -956,6 +990,102 @@ def upload_php_cli_binaries_to_apps_cdn(version:, artifacts_dir:, visibility:, e builds end +# The standalone Studio CLI bundles to publish, one per platform/arch. File names are +# versionless (the curl installers fetch them by this exact name); the CDN path's version +# comes from the upload's `version:`. `platform` maps to the Apps CDN platform label, the +# same labels the desktop app and PHP CLI uploads use. +def studio_cli_cdn_builds + [ + { key: :cli_mac_arm64, file_name: 'studio-cli-darwin-arm64.tgz', name: 'macOS arm64', platform: 'Mac - Silicon' }, + { key: :cli_mac_x64, file_name: 'studio-cli-darwin-x64.tgz', name: 'macOS x64', platform: 'Mac - Intel' }, + { key: :cli_windows_x64, file_name: 'studio-cli-win32-x64.tgz', name: 'Windows x64', platform: 'Windows - x64' }, + { key: :cli_windows_arm64, file_name: 'studio-cli-win32-arm64.tgz', name: 'Windows arm64', platform: 'Windows - ARM64' }, + { key: :cli_linux_x64, file_name: 'studio-cli-linux-x64.tgz', name: 'Linux x64', platform: 'Linux - x64' }, + { key: :cli_linux_arm64, file_name: 'studio-cli-linux-arm64.tgz', name: 'Linux arm64', platform: 'Linux - ARM64' } + ] +end + +# Upload (or update) the standalone Studio CLI bundles on the Apps CDN. +# +# Mirrors {upload_php_cli_binaries_to_apps_cdn} but targets the `WordPress.com Studio CLI` +# product. Each bundle is verified against its `.sha256` sidecar before upload. `build_type` +# and `visibility` are passed through from the caller so the CLI tracks the app exactly +# (External for nightly/beta, Internal-then-flipped-public for stable). `build_number` is nil +# so the CDN URL ends in `/full-install` (the path the curl installers expect). Returns a hash +# keyed per build including each upload's `post_id`, so the caller can fold it into the release +# builds and let publish_release flip visibility to External. +def upload_studio_cli_binaries_to_apps_cdn(version:, artifacts_dir:, build_type:, visibility:, error_on_duplicate:, dry_run: DRY_RUN) + normalized_visibility = visibility.to_s.downcase.to_sym + unless %i[internal external].include?(normalized_visibility) + UI.user_error!('visibility must be :internal or :external') + end + + builds = {} + studio_cli_cdn_builds.each do |build| + file_path = File.join(artifacts_dir, build[:file_name]) + + # A real upload requires every bundle; a dry run still previews the rest, so you + # can sanity-check the lane after building only your current platform locally. + unless File.exist?(file_path) + UI.user_error!("File #{file_path} does not exist") unless dry_run + UI.important("[DRY RUN] #{build[:file_name]} not found in #{artifacts_dir} — skipping (would upload to '#{WPCOM_STUDIO_CLI_PRODUCT}' / #{build[:platform]} / #{version}).") + next + end + + sha = read_sha256_sidecar(file_path:) + release_notes = "Studio CLI #{version} for #{build[:name]}" + UI.message("Uploading Studio CLI bundle: #{build[:file_name]}") + media_url = nil + post_id = nil + + if dry_run + UI.message('[DRY RUN] Upload step skipped due to dry run mode.') + UI.message(" File exists at: #{file_path}") + UI.message(" file size: #{File.size(file_path) / 1024 / 1024} MB") + UI.message(" product: #{WPCOM_STUDIO_CLI_PRODUCT}") + UI.message(" platform: #{build[:platform]}") + UI.message(" build type: #{build_type}") + UI.message(' install type: Full Install') + UI.message(" visibility: #{normalized_visibility}") + UI.message(" version: #{version}") + UI.message(" release notes: #{release_notes}") + UI.message(" sha: #{sha}") + UI.message(" error on duplicate: #{error_on_duplicate}") + else + result = upload_build_to_apps_cdn( + site_id: WPCOM_STUDIO_SITE_ID, + product: WPCOM_STUDIO_CLI_PRODUCT, + platform: build[:platform], + build_type: build_type, + install_type: 'Full Install', + visibility: normalized_visibility, + version: version, + build_number: nil, + release_notes: release_notes, + sha: sha, + file_path: file_path, + error_on_duplicate: error_on_duplicate + ) + media_url = result[:media_url] || result['media_url'] + post_id = result[:post_id] || result['post_id'] + UI.user_error!('Apps CDN upload response did not include media_url') if media_url.to_s.empty? + + UI.message('--------------------------------') + UI.message("✅ Uploaded Studio CLI bundle: #{file_path} to #{media_url}") + end + + builds[build[:key]] = { + name: build[:name], + platform: build[:platform], + cdn_url: media_url, + post_id: post_id, + sha: sha + } + end + + builds +end + ######################################################################## # Release Management Helper Methods ######################################################################## diff --git a/scripts/create-standalone-bundle.ts b/scripts/create-standalone-bundle.ts index 8ed5eab218..239350ada0 100644 --- a/scripts/create-standalone-bundle.ts +++ b/scripts/create-standalone-bundle.ts @@ -11,8 +11,8 @@ * cli/ CLI bundle (main.mjs, node_modules, wp-files, …) * * Output: - * standalone-bundles/studio-cli-{platform}-{arch}.tar.gz - * standalone-bundles/studio-cli-{platform}-{arch}.tar.gz.sha256 + * standalone-bundles/studio-cli-{platform}-{arch}.tgz + * standalone-bundles/studio-cli-{platform}-{arch}.tgz.sha256 * * Prerequisites: Node.js >= 22, npm dependencies installed * @@ -58,7 +58,7 @@ if ( ! supportedArchs.includes( archArg ) ) { } const isWindows = platformArg === 'win32'; -const bundleName = `studio-cli-${ platformArg }-${ archArg }.tar.gz`; +const bundleName = `studio-cli-${ platformArg }-${ archArg }.tgz`; const outputDir = path.join( repoRoot, 'standalone-bundles' ); const stagingDir = path.join( outputDir, `staging-${ platformArg }-${ archArg }` ); const cliDistDir = path.join( repoRoot, 'apps', 'cli', 'dist', 'cli' ); diff --git a/scripts/standalone/install.ps1 b/scripts/standalone/install.ps1 index 41d390e350..7b69c51054 100644 --- a/scripts/standalone/install.ps1 +++ b/scripts/standalone/install.ps1 @@ -9,13 +9,17 @@ # powershell -ExecutionPolicy Bypass -File scripts\standalone\install.ps1 # # Environment variables: -# STUDIO_CLI_HOME — Installation directory (default: %LOCALAPPDATA%\studio) -# STUDIO_CLI_URL — Base URL for downloading bundles (default: https://wp.build/releases) +# STUDIO_CLI_HOME — Installation directory (default: %LOCALAPPDATA%\studio) +# STUDIO_CLI_VERSION — Version to install from the CDN (default: latest, e.g. v1.11.0) +# STUDIO_CLI_URL — Override the download source with a base URL or local dir, +# bypassing the CDN. Expects studio-cli--.tgz +# plus a matching .sha256 sidecar (used for testing and mirrors). $ErrorActionPreference = "Stop" $InstallDir = if ($env:STUDIO_CLI_HOME) { $env:STUDIO_CLI_HOME } else { "$env:LOCALAPPDATA\studio" } -$BaseUrl = if ($env:STUDIO_CLI_URL) { $env:STUDIO_CLI_URL } else { "https://wp.build/releases" } +$CdnBase = "https://appscdn.wordpress.com/downloads/wordpress-com-studio-cli" +$CdnVersion = if ($env:STUDIO_CLI_VERSION) { $env:STUDIO_CLI_VERSION } else { "latest" } # --- Platform detection --- @@ -59,8 +63,21 @@ function Test-Checksum { function Install-StudioCli { $Arch = Get-Platform - $BundleName = "studio-cli-win32-${Arch}.tar.gz" - $BundleUrl = "${BaseUrl}/${BundleName}" + $BundleName = "studio-cli-win32-${Arch}.tgz" + + # Default to the Apps CDN, which 302-redirects "latest" (or a pinned version) to + # the newest published bundle. STUDIO_CLI_URL overrides this with a base URL or + # local dir that serves the bundle by name plus a .sha256 sidecar — used for + # local testing, mirrors, or pinning an arbitrary build. + if ($env:STUDIO_CLI_URL) { + $BundleUrl = "$($env:STUDIO_CLI_URL)/${BundleName}" + $HasSha256Sidecar = $true + } + else { + $Slug = if ($Arch -eq "arm64") { "windows-arm64" } else { "windows-x64" } + $BundleUrl = "${CdnBase}/${Slug}/${CdnVersion}/full-install" + $HasSha256Sidecar = $false + } Write-Host "Studio CLI Installer" Write-Host "" @@ -69,9 +86,9 @@ function Install-StudioCli { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null $BinDir = Join-Path $InstallDir "bin" - # Download, verify, and extract in a staging dir, then swap the runtime - # dirs into place. A failed or corrupt download never clobbers a working - # install. + # Download and extract in a staging dir, then swap the runtime dirs into place. + # A failed, corrupt, or truncated download fails at extraction below and never + # clobbers a working install. $StagingDir = Join-Path $InstallDir (".studio-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null try { @@ -79,10 +96,15 @@ function Install-StudioCli { Write-Host "Downloading Studio CLI..." Get-Bundle -Url $BundleUrl -Dest $TmpBundle - Get-Bundle -Url "$BundleUrl.sha256" -Dest "$TmpBundle.sha256" - Write-Host "Verifying checksum..." - Test-Checksum -File $TmpBundle -ChecksumFile "$TmpBundle.sha256" + # The CDN exposes the SHA-256 only as build metadata, not as a downloadable + # sidecar, so checksum verification applies to STUDIO_CLI_URL sources (which + # do ship one). The CDN path relies on HTTPS plus the extraction guard below. + if ($HasSha256Sidecar) { + Get-Bundle -Url "$BundleUrl.sha256" -Dest "$TmpBundle.sha256" + Write-Host "Verifying checksum..." + Test-Checksum -File $TmpBundle -ChecksumFile "$TmpBundle.sha256" + } Write-Host "Installing to $InstallDir..." $ExtractDir = Join-Path $StagingDir "extracted" diff --git a/scripts/standalone/install.sh b/scripts/standalone/install.sh index f4b44ad54a..0e8d2ec8fd 100755 --- a/scripts/standalone/install.sh +++ b/scripts/standalone/install.sh @@ -5,12 +5,16 @@ set -eu # Usage: curl -fsSL https://wp.build/install.sh | bash # # Environment variables: -# STUDIO_CLI_HOME — Installation directory (default: ~/.studio) -# STUDIO_CLI_URL — Base URL for downloading bundles (default: https://wp.build/releases) +# STUDIO_CLI_HOME — Installation directory (default: ~/.studio) +# STUDIO_CLI_VERSION — Version to install from the CDN (default: latest, e.g. v1.11.0) +# STUDIO_CLI_URL — Override the download source with a base URL or local dir, +# bypassing the CDN. Expects studio-cli--.tgz +# plus a matching .sha256 sidecar (used for testing and mirrors). INSTALL_DIR="${STUDIO_CLI_HOME:-$HOME/.studio}" -BASE_URL="${STUDIO_CLI_URL:-https://wp.build/releases}" BIN_DIR="$HOME/.local/bin" +CDN_BASE="https://appscdn.wordpress.com/downloads/wordpress-com-studio-cli" +CDN_VERSION="${STUDIO_CLI_VERSION:-latest}" # --- Platform detection --- @@ -37,6 +41,14 @@ detect_platform() { ;; esac + # Apps CDN platform slug used to build the default download path. + case "$PLATFORM-$ARCH" in + darwin-arm64) SLUG="mac-silicon" ;; + darwin-x64) SLUG="mac-intel" ;; + linux-x64) SLUG="linux-x64" ;; + linux-arm64) SLUG="linux-arm64" ;; + esac + echo "Detected platform: $PLATFORM-$ARCH" } @@ -93,14 +105,25 @@ verify_checksum() { # --- Install --- install_studio() { - BUNDLE_NAME="studio-cli-${PLATFORM}-${ARCH}.tar.gz" - BUNDLE_URL="${BASE_URL}/${BUNDLE_NAME}" + BUNDLE_NAME="studio-cli-${PLATFORM}-${ARCH}.tgz" + + # Default to the Apps CDN, which 302-redirects "latest" (or a pinned version) to + # the newest published bundle. STUDIO_CLI_URL overrides this with a base URL or + # local dir that serves the bundle by name plus a .sha256 sidecar — used for + # local testing, mirrors, or pinning an arbitrary build. + if [ -n "${STUDIO_CLI_URL:-}" ]; then + BUNDLE_URL="${STUDIO_CLI_URL}/${BUNDLE_NAME}" + HAS_SHA256_SIDECAR=1 + else + BUNDLE_URL="${CDN_BASE}/${SLUG}/${CDN_VERSION}/full-install" + HAS_SHA256_SIDECAR=0 + fi mkdir -p "$INSTALL_DIR" - # Download, verify, and extract in a staging dir on the same filesystem as - # the final paths. A failed or corrupt download never touches a previously - # working install. + # Download and extract in a staging dir on the same filesystem as the final + # paths. A failed, corrupt, or truncated download fails at extraction below and + # never touches a previously working install. STAGING_DIR="$(mktemp -d "${INSTALL_DIR}/.studio-install.XXXXXX")" trap 'rm -rf "$STAGING_DIR"' EXIT @@ -108,17 +131,35 @@ install_studio() { echo "Downloading Studio CLI..." download "$BUNDLE_URL" "$TMP_BUNDLE" - download "${BUNDLE_URL}.sha256" "$TMP_BUNDLE.sha256" - echo "Verifying checksum..." - if ! verify_checksum "$TMP_BUNDLE" "$TMP_BUNDLE.sha256"; then - echo "Error: checksum verification failed. Aborting; existing install left untouched." >&2 - exit 1 + # The CDN exposes the SHA-256 only as build metadata, not as a downloadable + # sidecar, so checksum verification applies to STUDIO_CLI_URL sources (which do + # ship one). The CDN path relies on HTTPS for transport integrity plus the + # staging-extraction guard below. + if [ "$HAS_SHA256_SIDECAR" = "1" ]; then + download "${BUNDLE_URL}.sha256" "$TMP_BUNDLE.sha256" + echo "Verifying checksum..." + if ! verify_checksum "$TMP_BUNDLE" "$TMP_BUNDLE.sha256"; then + echo "Error: checksum verification failed. Aborting; existing install left untouched." >&2 + exit 1 + fi fi echo "Installing to $INSTALL_DIR..." mkdir -p "$STAGING_DIR/extracted" - tar -xzf "$TMP_BUNDLE" -C "$STAGING_DIR/extracted" + if ! tar -xzf "$TMP_BUNDLE" -C "$STAGING_DIR/extracted"; then + echo "Error: failed to extract bundle (corrupt or incomplete download). Aborting; existing install left untouched." >&2 + exit 1 + fi + + # macOS tags browser-downloaded archives with com.apple.quarantine, and tar + # propagates it to the extracted files — which makes Gatekeeper refuse to load + # the unsigned native .node modules ("library load disallowed by system policy"). + # A curl/wget install isn't quarantined, but clear it defensively so installs from + # a manually-downloaded bundle (or on quarantine-strict/MDM Macs) still work. + if [ "$(uname)" = "Darwin" ]; then + xattr -dr com.apple.quarantine "$STAGING_DIR/extracted" 2>/dev/null || true + fi # A previous standalone install may have a running daemon and site servers # holding open handles on bin/node and cli/. Stop them first so replacing the