diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index b9a162313..af4757549 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -19,13 +19,13 @@ on: type: boolean default: true enable_linux: - description: "Build and publish Linux x64 AppImage beta artifacts." + description: "Build and publish Linux x64 AppImage/checksum to R2 only; no updater feed is published yet." required: true type: boolean default: false permissions: - contents: write + contents: read concurrency: group: open-design-release-beta @@ -36,19 +36,17 @@ jobs: name: Prepare beta metadata runs-on: ubuntu-latest env: - GH_TOKEN: ${{ github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} + OPEN_DESIGN_BETA_METADATA_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}/beta/latest/metadata.json OPEN_DESIGN_RELEASE_SIGNED: ${{ inputs.signed }} outputs: asset_version_suffix: ${{ steps.beta.outputs.asset_version_suffix }} base_version: ${{ steps.beta.outputs.base_version }} - beta_tag: ${{ steps.beta.outputs.beta_tag }} beta_version: ${{ steps.beta.outputs.beta_version }} branch: ${{ steps.beta.outputs.branch }} commit: ${{ steps.beta.outputs.commit }} release_name: ${{ steps.beta.outputs.release_name }} signed: ${{ steps.beta.outputs.signed }} - version_tag: ${{ steps.beta.outputs.version_tag }} + state_source: ${{ steps.beta.outputs.state_source }} steps: - name: Checkout uses: actions/checkout@v6.0.2 @@ -60,6 +58,42 @@ jobs: with: node-version: 24 + - name: Validate beta publish inputs + run: | + set -euo pipefail + if [ "${{ inputs.enable_mac }}" != "true" ] && [ "${{ inputs.enable_win }}" != "true" ] && [ "${{ inputs.enable_linux }}" != "true" ]; then + echo "release-beta requires at least one platform to be enabled" >&2 + exit 1 + fi + + - name: Validate R2 release access + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_RELEASES_AK }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_RELEASES_SK }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: "true" + CLOUDFLARE_R2_RELEASES_BUCKET: ${{ secrets.CLOUDFLARE_R2_RELEASES_BUCKET }} + CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + CLOUDFLARE_R2_RELEASES_URL: ${{ secrets.CLOUDFLARE_R2_RELEASES_URL }} + run: | + set -euo pipefail + for name in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY CLOUDFLARE_R2_RELEASES_BUCKET CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN CLOUDFLARE_R2_RELEASES_URL; do + if [ -z "${!name}" ]; then + echo "$name is required" >&2 + exit 1 + fi + done + probe_file="$RUNNER_TEMP/r2-release-access.txt" + probe_key="beta/.ci-access-check/release-beta.txt" + printf 'run=%s\nsha=%s\n' "$GITHUB_RUN_ID" "$GITHUB_SHA" > "$probe_file" + aws --endpoint-url "${CLOUDFLARE_R2_RELEASES_URL%/}" s3api put-object \ + --bucket "$CLOUDFLARE_R2_RELEASES_BUCKET" \ + --key "$probe_key" \ + --body "$probe_file" \ + --content-type "text/plain; charset=utf-8" \ + --cache-control "no-store" \ + --no-cli-pager >/dev/null + - name: Prepare beta release metadata id: beta run: node --experimental-strip-types ./scripts/release-beta.ts @@ -69,8 +103,6 @@ jobs: needs: metadata if: ${{ inputs.enable_mac }} runs-on: macos-14 - env: - GH_TOKEN: ${{ github.token }} steps: - name: Checkout uses: actions/checkout@v6.0.2 @@ -86,6 +118,24 @@ jobs: uses: actions/setup-node@v6 with: node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Compute mac tools-pack cache key + id: mac_tools_pack_cache_key + run: | + set -euo pipefail + echo "epoch=$(date -u +%Y-%m)" >> "$GITHUB_OUTPUT" + + - name: Restore mac tools-pack cache + id: mac_tools_pack_cache_restore + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/tools-pack-cache + key: tools-pack-mac-v1-${{ runner.os }}-${{ steps.mac_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }} + restore-keys: | + tools-pack-mac-v1-${{ runner.os }}-${{ steps.mac_tools_pack_cache_key.outputs.epoch }}- - name: Install dependencies run: pnpm install --frozen-lockfile @@ -116,18 +166,44 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - signed_flag="" + tools_pack_dir="$RUNNER_TEMP/tools-pack" + cache_dir="$RUNNER_TEMP/tools-pack-cache" + build_json_path="$RUNNER_TEMP/mac-tools-pack-build.json" + build_args=( + exec tools-pack mac build + --dir "$tools_pack_dir" + --cache-dir "$cache_dir" + --namespace release-beta + --portable + --mac-compression maximum + --to all + --json + ) if [ "${{ inputs.signed }}" = "true" ]; then - signed_flag="--signed" + build_args+=(--signed) + fi + if build_output="$(pnpm "${build_args[@]}")"; then + printf '%s\n' "$build_output" | tee "$build_json_path" + else + cached_status=$? + printf '%s\n' "$build_output" + echo "mac tools-pack cached build failed with exit code $cached_status; removing cache and retrying without cache" >&2 + rm -rf "$cache_dir" + fallback_args=( + exec tools-pack mac build + --dir "$tools_pack_dir" + --namespace release-beta + --portable + --mac-compression maximum + --to all + --json + ) + if [ "${{ inputs.signed }}" = "true" ]; then + fallback_args+=(--signed) + fi + fallback_output="$(pnpm "${fallback_args[@]}")" + printf '%s\n' "$fallback_output" | tee "$build_json_path" fi - pnpm exec tools-pack mac build \ - --dir "$RUNNER_TEMP/tools-pack" \ - --namespace release-beta \ - --portable \ - --mac-compression normal \ - --to all \ - --json \ - $signed_flag - name: Smoke beta mac packaged runtime working-directory: e2e @@ -137,10 +213,116 @@ jobs: OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack run: pnpm test specs/mac.spec.ts - - name: Prepare beta assets - id: assets + - name: Prune mac tools-pack cache + continue-on-error: true run: | set -euo pipefail + cache_root="$RUNNER_TEMP/tools-pack-cache" + if [ ! -d "$cache_root" ]; then + echo "tools-pack cache root does not exist; nothing to prune" + exit 0 + fi + rm -rf "$cache_root/locks" + CACHE_ROOT="$cache_root" node --input-type=module <<'NODE' + import { rmSync, statSync, readdirSync } from "node:fs"; + import { join } from "node:path"; + + const cacheRoot = process.env.CACHE_ROOT; + const entryRoot = join(cacheRoot, "entries"); + const maxBytes = 6 * 1024 * 1024 * 1024; + const entries = []; + + function directoryBytes(path) { + let total = 0; + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) total += directoryBytes(child); + else total += statSync(child).size; + } + return total; + } + + try { + for (const node of readdirSync(entryRoot, { withFileTypes: true })) { + if (!node.isDirectory()) continue; + const nodeRoot = join(entryRoot, node.name); + for (const entry of readdirSync(nodeRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const path = join(nodeRoot, entry.name); + const size = directoryBytes(path); + entries.push({ node: node.name, path, size, mtimeMs: statSync(path).mtimeMs }); + } + } + } catch { + console.log("tools-pack cache entries root does not exist; nothing to prune"); + process.exit(0); + } + + entries.sort((left, right) => right.mtimeMs - left.mtimeMs); + let keptBytes = 0; + let removedBytes = 0; + let removedCount = 0; + for (const entry of entries) { + if (keptBytes + entry.size <= maxBytes) { + keptBytes += entry.size; + continue; + } + rmSync(entry.path, { force: true, recursive: true }); + removedBytes += entry.size; + removedCount += 1; + } + console.log(`keptBytes=${keptBytes} removedBytes=${removedBytes} removedCount=${removedCount} maxBytes=${maxBytes}`); + NODE + + - name: Summarize mac tools-pack build + continue-on-error: true + run: | + set -euo pipefail + build_json_path="$RUNNER_TEMP/mac-tools-pack-build.json" + if [ ! -f "$build_json_path" ]; then + { + echo "### mac tools-pack build" + echo + echo "Build JSON was not found at \`$build_json_path\`." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + BUILD_JSON_PATH="$build_json_path" node --input-type=module <<'NODE' >> "$GITHUB_STEP_SUMMARY" + import { readFileSync } from "node:fs"; + const build = JSON.parse(readFileSync(process.env.BUILD_JSON_PATH, "utf8")); + console.log("### mac tools-pack build"); + console.log(""); + console.log("| Phase | Duration |"); + console.log("| --- | ---: |"); + for (const timing of build.timings ?? []) { + console.log(`| \`${timing.phase}\` | ${(Number(timing.durationMs) / 1000).toFixed(1)}s |`); + } + console.log(""); + console.log("| Cache node | Status | Reason | Duration |"); + console.log("| --- | --- | --- | ---: |"); + for (const entry of build.cacheReport?.entries ?? []) { + console.log(`| \`${entry.nodeId}\` | \`${entry.status}\` | ${entry.reason ?? ""} | ${(Number(entry.durationMs) / 1000).toFixed(1)}s |`); + } + NODE + + - name: Save mac tools-pack cache + if: ${{ success() && steps.mac_tools_pack_cache_restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/tools-pack-cache + key: tools-pack-mac-v1-${{ runner.os }}-${{ steps.mac_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }} + + - name: Prepare beta assets + id: assets + env: + CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }} + run: | + set -euo pipefail + if [ -z "$CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN" ]; then + echo "CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN is required" >&2 + exit 1 + fi release_dir="$RUNNER_TEMP/release-assets" mkdir -p "$release_dir" @@ -171,7 +353,9 @@ jobs: zip_sha512="$(openssl dgst -sha512 -binary "$release_dir/$versioned_zip" | openssl base64 -A)" zip_size="$(stat -f%z "$release_dir/$versioned_zip")" - zip_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${{ needs.metadata.outputs.version_tag }}/$versioned_zip" + public_origin="${CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN%/}" + version_prefix="beta/versions/${{ needs.metadata.outputs.beta_version }}${asset_suffix}" + zip_url="$public_origin/$version_prefix/$versioned_zip" release_date="$(date -u +%Y-%m-%dT%H:%M:%SZ)" cat > "$release_dir/latest-mac.yml" <&2 + exit 1 + fi + done - - name: Write release notes - id: notes - run: | - set -euo pipefail - version_notes_file="$RUNNER_TEMP/open-design-beta-version-notes.md" - latest_notes_file="$RUNNER_TEMP/open-design-beta-latest-notes.md" - cat > "$version_notes_file" < "$latest_notes_file" <&2 + exit 1 + fi + aws --endpoint-url "${CLOUDFLARE_R2_RELEASES_URL%/}" s3api put-object \ + --bucket "$CLOUDFLARE_R2_RELEASES_BUCKET" \ + --key "$object_key" \ + --body "$file_path" \ + --content-type "$content_type" \ + --cache-control "$cache_control" \ + --no-cli-pager >/dev/null + } + + mac_dmg="open-design-${BETA_VERSION}${ASSET_VERSION_SUFFIX}-mac-arm64.dmg" + mac_zip="open-design-${BETA_VERSION}${ASSET_VERSION_SUFFIX}-mac-arm64.zip" + win_installer="open-design-${BETA_VERSION}.unsigned-win-x64-setup.exe" + linux_appimage="open-design-${BETA_VERSION}.unsigned-linux-x64.AppImage" + metadata_path="$release_root/metadata.json" + + if [ "$ENABLE_MAC" = "true" ]; then + upload "$release_root/mac/$mac_dmg" "$version_prefix/$mac_dmg" "application/x-apple-diskimage" "public, max-age=31536000, immutable" + upload "$release_root/mac/$mac_zip" "$version_prefix/$mac_zip" "application/zip" "public, max-age=31536000, immutable" + upload "$release_root/mac/$mac_dmg.sha256" "$version_prefix/$mac_dmg.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable" + upload "$release_root/mac/$mac_zip.sha256" "$version_prefix/$mac_zip.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable" + upload "$release_root/mac/latest-mac.yml" "$version_prefix/latest-mac.yml" "application/x-yaml; charset=utf-8" "public, max-age=31536000, immutable" + upload "$release_root/mac/latest-mac.yml" "$latest_prefix/latest-mac.yml" "application/x-yaml; charset=utf-8" "public, max-age=60, must-revalidate" + { + echo "mac_dmg_url=$public_origin/$version_prefix/$mac_dmg" + echo "mac_zip_url=$public_origin/$version_prefix/$mac_zip" + echo "mac_feed_url=$public_origin/$latest_prefix/latest-mac.yml" + } >> "$GITHUB_OUTPUT" + fi + + if [ "$ENABLE_WIN" = "true" ]; then + upload "$release_root/win/$win_installer" "$version_prefix/$win_installer" "application/vnd.microsoft.portable-executable" "public, max-age=31536000, immutable" + upload "$release_root/win/$win_installer.sha256" "$version_prefix/$win_installer.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable" + upload "$release_root/win/latest.yml" "$version_prefix/latest.yml" "application/x-yaml; charset=utf-8" "public, max-age=31536000, immutable" + upload "$release_root/win/latest.yml" "$latest_prefix/latest.yml" "application/x-yaml; charset=utf-8" "public, max-age=60, must-revalidate" + { + echo "win_installer_url=$public_origin/$version_prefix/$win_installer" + echo "win_feed_url=$public_origin/$latest_prefix/latest.yml" + } >> "$GITHUB_OUTPUT" + fi + + if [ "$ENABLE_LINUX" = "true" ]; then + upload "$release_root/linux/$linux_appimage" "$version_prefix/$linux_appimage" "application/octet-stream" "public, max-age=31536000, immutable" + upload "$release_root/linux/$linux_appimage.sha256" "$version_prefix/$linux_appimage.sha256" "text/plain; charset=utf-8" "public, max-age=31536000, immutable" + { + echo "linux_appimage_url=$public_origin/$version_prefix/$linux_appimage" + } >> "$GITHUB_OUTPUT" + fi + + RELEASE_ROOT="$release_root" \ + PUBLIC_ORIGIN="$public_origin" \ + VERSION_PREFIX="$version_prefix" \ + LATEST_PREFIX="$latest_prefix" \ + MAC_DMG="$mac_dmg" \ + MAC_ZIP="$mac_zip" \ + WIN_INSTALLER="$win_installer" \ + LINUX_APPIMAGE="$linux_appimage" \ + METADATA_PATH="$metadata_path" \ + node --input-type=module <<'NODE' + import { existsSync, statSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + + const env = process.env; + const enabled = (name) => env[name] === "true"; + const publicOrigin = env.PUBLIC_ORIGIN; + const versionPrefix = env.VERSION_PREFIX; + const latestPrefix = env.LATEST_PREFIX; + const releaseRoot = env.RELEASE_ROOT; + const url = (prefix, name) => `${publicOrigin}/${prefix}/${name}`; + const mustExist = (path) => { + if (!existsSync(path)) throw new Error(`metadata source file missing: ${path}`); + return statSync(path).size; + }; + const fileEntry = (directory, name, contentType) => { + const path = join(releaseRoot, directory, name); + return { + contentType, + name, + sha256Url: url(versionPrefix, `${name}.sha256`), + size: mustExist(path), + url: url(versionPrefix, name), + }; + }; + + const platforms = {}; + if (enabled("ENABLE_MAC")) { + platforms.mac = { + arch: "arm64", + enabled: true, + feed: { + latestUrl: url(latestPrefix, "latest-mac.yml"), + name: "latest-mac.yml", + url: url(versionPrefix, "latest-mac.yml"), + }, + signed: env.RELEASE_SIGNED === "true", + artifacts: { + dmg: fileEntry("mac", env.MAC_DMG, "application/x-apple-diskimage"), + zip: fileEntry("mac", env.MAC_ZIP, "application/zip"), + }, + }; + } + if (enabled("ENABLE_WIN")) { + platforms.win = { + arch: "x64", + enabled: true, + feed: { + latestUrl: url(latestPrefix, "latest.yml"), + name: "latest.yml", + url: url(versionPrefix, "latest.yml"), + }, + signed: false, + artifacts: { + installer: fileEntry("win", env.WIN_INSTALLER, "application/vnd.microsoft.portable-executable"), + }, + }; + } + if (enabled("ENABLE_LINUX")) { + platforms.linux = { + arch: "x64", + enabled: true, + feed: null, + signed: false, + artifacts: { + appImage: fileEntry("linux", env.LINUX_APPIMAGE, "application/octet-stream"), + }, + }; + } + + const metadata = { + assetVersionSuffix: env.ASSET_VERSION_SUFFIX, + baseVersion: env.BASE_VERSION, + betaNumber: Number(env.BETA_VERSION.split("-beta.")[1]), + betaVersion: env.BETA_VERSION, + channel: "beta", + generatedAt: new Date().toISOString(), + github: { + branch: env.BRANCH_NAME, + commit: env.GITHUB_SHA, + repository: env.GITHUB_REPOSITORY, + runAttempt: Number(env.GITHUB_RUN_ATTEMPT), + runId: Number(env.GITHUB_RUN_ID), + workflow: env.GITHUB_WORKFLOW, + }, + platforms, + r2: { + latestMetadataUrl: url(latestPrefix, "metadata.json"), + latestPrefix, + publicOrigin, + versionMetadataUrl: url(versionPrefix, "metadata.json"), + versionPrefix, + }, + signed: env.RELEASE_SIGNED === "true", + stateSource: env.STATE_SOURCE, + version: 1, + }; + + writeFileSync(env.METADATA_PATH, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); + NODE + + upload "$metadata_path" "$version_prefix/metadata.json" "application/json; charset=utf-8" "public, max-age=31536000, immutable" + upload "$metadata_path" "$latest_prefix/metadata.json" "application/json; charset=utf-8" "public, max-age=60, must-revalidate" - This release is the mutable beta channel feed carrier. It should contain enabled feed assets only: latest-mac.yml and/or latest.yml. If neither mac nor Windows is enabled, no feed assets are expected. - EOF { - echo "version_notes_file=$version_notes_file" - echo "latest_notes_file=$latest_notes_file" + echo "metadata_url=$public_origin/$latest_prefix/metadata.json" + echo "version_metadata_url=$public_origin/$version_prefix/metadata.json" + echo "version_prefix=$version_prefix" } >> "$GITHUB_OUTPUT" - - name: Create or update immutable beta prerelease + - name: Verify R2 beta publish run: | set -euo pipefail - all_release_dir="$RUNNER_TEMP/release-assets/all" - mkdir -p "$all_release_dir" - for asset_dir in "$RUNNER_TEMP/release-assets/mac" "$RUNNER_TEMP/release-assets/win" "$RUNNER_TEMP/release-assets/linux"; do - if [ -d "$asset_dir" ] && compgen -G "$asset_dir/*" > /dev/null; then - cp "$asset_dir"/* "$all_release_dir/" - fi - done - if ! compgen -G "$all_release_dir/*" > /dev/null; then - echo "no enabled beta release assets were found" >&2 - exit 1 - fi - declare -A current_release_assets=() - for asset_path in "$all_release_dir"/*; do - current_release_assets["$(basename "$asset_path")"]=1 - done - if gh release view "${{ needs.metadata.outputs.version_tag }}" >/dev/null 2>&1; then - gh release edit "${{ needs.metadata.outputs.version_tag }}" \ - --title "${{ needs.metadata.outputs.release_name }}" \ - --notes-file "${{ steps.notes.outputs.version_notes_file }}" \ - --prerelease - else - gh release create "${{ needs.metadata.outputs.version_tag }}" \ - --target "$GITHUB_SHA" \ - --title "${{ needs.metadata.outputs.release_name }}" \ - --notes-file "${{ steps.notes.outputs.version_notes_file }}" \ - --prerelease - fi - gh release upload "${{ needs.metadata.outputs.version_tag }}" "$all_release_dir"/* --clobber - while IFS= read -r asset_name; do - if [ -n "$asset_name" ] && [ -z "${current_release_assets[$asset_name]+x}" ]; then - gh release delete-asset "${{ needs.metadata.outputs.version_tag }}" "$asset_name" --yes - fi - done < <(gh release view "${{ needs.metadata.outputs.version_tag }}" --json assets --jq '.assets[].name') + metadata_url="${{ steps.r2.outputs.metadata_url }}" + downloaded_metadata="$RUNNER_TEMP/metadata.json" + curl -fsSL "$metadata_url?run=${GITHUB_RUN_ID}" -o "$downloaded_metadata" + DOWNLOADED_METADATA="$downloaded_metadata" \ + EXPECTED_BETA_VERSION="${{ needs.metadata.outputs.beta_version }}" \ + node --input-type=module <<'NODE' + import { readFileSync } from "node:fs"; + const metadata = JSON.parse(readFileSync(process.env.DOWNLOADED_METADATA, "utf8")); + if (metadata.betaVersion !== process.env.EXPECTED_BETA_VERSION) { + throw new Error("unexpected metadata betaVersion: " + metadata.betaVersion); + } + if (metadata.channel !== "beta") { + throw new Error("unexpected metadata channel: " + metadata.channel); + } + NODE - - name: Create or update beta channel feed - run: | - set -euo pipefail - latest_mac_path="$RUNNER_TEMP/release-assets/mac/latest-mac.yml" - latest_win_path="$RUNNER_TEMP/release-assets/win/latest.yml" - feed_assets=() if [ "$ENABLE_MAC" = "true" ]; then - if [ ! -f "$latest_mac_path" ]; then - echo "expected mac feed not found at $latest_mac_path" >&2 - exit 1 - fi - feed_assets+=("$latest_mac_path") + downloaded_feed="$RUNNER_TEMP/latest-mac.yml" + curl -fsSL "${{ steps.r2.outputs.mac_feed_url }}?run=${GITHUB_RUN_ID}" -o "$downloaded_feed" + grep -F 'version: "${{ needs.metadata.outputs.beta_version }}"' "$downloaded_feed" + grep -F "${{ steps.r2.outputs.mac_zip_url }}" "$downloaded_feed" + curl -fsSI "${{ steps.r2.outputs.mac_zip_url }}" >/dev/null + curl -fsSI "${{ steps.r2.outputs.mac_dmg_url }}" >/dev/null fi + if [ "$ENABLE_WIN" = "true" ]; then - if [ ! -f "$latest_win_path" ]; then - echo "expected windows feed not found at $latest_win_path" >&2 - exit 1 - fi - feed_assets+=("$latest_win_path") + downloaded_feed="$RUNNER_TEMP/latest.yml" + curl -fsSL "${{ steps.r2.outputs.win_feed_url }}?run=${GITHUB_RUN_ID}" -o "$downloaded_feed" + grep -F 'version: "${{ needs.metadata.outputs.beta_version }}"' "$downloaded_feed" + grep -F "${{ steps.r2.outputs.win_installer_url }}" "$downloaded_feed" + curl -fsSI "${{ steps.r2.outputs.win_installer_url }}" >/dev/null fi - declare -A current_feed_assets=() - for feed_asset in "${feed_assets[@]}"; do - current_feed_assets["$(basename "$feed_asset")"]=1 - done - if gh release view "${{ needs.metadata.outputs.beta_tag }}" >/dev/null 2>&1; then - gh release edit "${{ needs.metadata.outputs.beta_tag }}" \ - --title "Open Design Beta Latest" \ - --notes-file "${{ steps.notes.outputs.latest_notes_file }}" \ - --prerelease - else - gh release create "${{ needs.metadata.outputs.beta_tag }}" \ - --target "$GITHUB_SHA" \ - --title "Open Design Beta Latest" \ - --notes-file "${{ steps.notes.outputs.latest_notes_file }}" \ - --prerelease + + if [ "$ENABLE_LINUX" = "true" ]; then + curl -fsSI "${{ steps.r2.outputs.linux_appimage_url }}" >/dev/null fi - if [ "${#feed_assets[@]}" -gt 0 ]; then - gh release upload "${{ needs.metadata.outputs.beta_tag }}" "${feed_assets[@]}" --clobber - fi - while IFS= read -r asset_name; do - if [ -n "$asset_name" ] && [ -z "${current_feed_assets[$asset_name]+x}" ]; then - gh release delete-asset "${{ needs.metadata.outputs.beta_tag }}" "$asset_name" --yes - fi - done < <(gh release view "${{ needs.metadata.outputs.beta_tag }}" --json assets --jq '.assets[].name') - name: Publish summary run: | @@ -738,21 +1045,26 @@ jobs: echo "## Beta release" echo "- Channel: beta" echo "- Version: ${{ needs.metadata.outputs.beta_version }}" - echo "- Version tag: ${{ needs.metadata.outputs.version_tag }}" - echo "- Channel feed tag: ${{ needs.metadata.outputs.beta_tag }}" + echo "- Artifact path: ${{ steps.r2.outputs.version_prefix }}" + echo "- State source: ${{ needs.metadata.outputs.state_source }}" + echo "- Metadata: ${{ steps.r2.outputs.metadata_url }}" echo "- mac enabled: $ENABLE_MAC" - echo "- mac signed/notarized: ${{ needs.metadata.outputs.signed }}" echo "- windows enabled: $ENABLE_WIN" - echo "- windows signed: false" echo "- linux enabled: $ENABLE_LINUX" + echo "- mac signed/notarized: ${{ needs.metadata.outputs.signed }}" if [ "$ENABLE_MAC" = "true" ]; then - echo "- mac assets: open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.dmg, open-design-${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}-mac-arm64.zip" + echo "- mac dmg: ${{ steps.r2.outputs.mac_dmg_url }}" + echo "- mac zip: ${{ steps.r2.outputs.mac_zip_url }}" + echo "- mac feed: ${{ steps.r2.outputs.mac_feed_url }}" fi if [ "$ENABLE_WIN" = "true" ]; then - echo "- win assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-win-x64-setup.exe" + echo "- win installer: ${{ steps.r2.outputs.win_installer_url }}" + echo "- win feed: ${{ steps.r2.outputs.win_feed_url }}" fi if [ "$ENABLE_LINUX" = "true" ]; then - echo "- linux assets: open-design-${{ needs.metadata.outputs.beta_version }}.unsigned-linux-x64.AppImage" + echo "- linux AppImage: ${{ steps.r2.outputs.linux_appimage_url }}" + echo "- linux feed: not published; AppImage updater is not wired" fi - echo "- Feeds: enabled mac/win feeds only (no latest-linux.yml; AppImage updater not yet wired)" + echo "- GitHub Releases: not used for beta channel publishing" + echo "- Git tags: not used for beta channel publishing" } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 5e841d890..89a233315 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -154,7 +154,7 @@ jobs: --dir "$RUNNER_TEMP/tools-pack" \ --namespace release-stable \ --portable \ - --mac-compression normal \ + --mac-compression maximum \ --to all \ --json \ $signed_flag diff --git a/README.ar.md b/README.ar.md index 6a9dd8a85..87a2ab53a 100644 --- a/README.ar.md +++ b/README.ar.md @@ -322,8 +322,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -مشغّل Windows: ابنِ `OpenDesign.exe` بنفسك باتباع التعليمات في `tools/launcher/README.md`، أو نزّله من GitHub Releases. بعد ذلك ضعه في جذر المستودع وانقر عليه مرتين ليشغّل `pnpm install` عند الحاجة ثم يبدأ Open Design عبر `pnpm tools-dev`. - متطلّبات البيئة: Node `~24` و pnpm `10.33.x`. أدوات `nvm`/`fnm` اختيارية فقط؛ إن استخدمت إحداها فشغّل `nvm install 24 && nvm use 24` أو `fnm install 24 && fnm use 24` قبل `pnpm install`. لتشغيل سطح المكتب / الخلفية، إعادة التشغيل بمنافذ ثابتة، وفحوص dispatcher توليد الوسائط (`OD_BIN`، `OD_DAEMON_URL`، `apps/daemon/dist/cli.js`) راجع [`QUICKSTART.md`](QUICKSTART.md). diff --git a/README.de.md b/README.de.md index e7f540bf9..9a079c7be 100644 --- a/README.de.md +++ b/README.de.md @@ -319,8 +319,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -Windows-Launcher: Erstellen Sie `OpenDesign.exe` selbst mit der Anleitung in `tools/launcher/README.md`, oder laden Sie ihn aus GitHub Releases herunter. Legen Sie die Datei danach in den Repository-Stamm und doppelklicken Sie sie, um bei Bedarf `pnpm install` auszuführen und Open Design mit `pnpm tools-dev` zu starten. - Umgebungsanforderungen: Node `~24` und pnpm `10.33.x`. `nvm`/`fnm` sind nur optionale Helfer; wenn Sie eines davon nutzen, führen Sie vor `pnpm install` `nvm install 24 && nvm use 24` oder `fnm install 24 && fnm use 24` aus. Für Desktop-/Background-Start, Fixed-Port-Restarts und Media-Generation-Dispatcher-Checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) siehe [`QUICKSTART.de.md`](QUICKSTART.de.md). diff --git a/README.es.md b/README.es.md index 46291d51e..e0401c790 100644 --- a/README.es.md +++ b/README.es.md @@ -310,8 +310,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -Lanzador de Windows: compila `OpenDesign.exe` con las instrucciones de `tools/launcher/README.md` o descárgalo desde GitHub Releases. Después colócalo en la raíz del repo y haz doble clic para ejecutar `pnpm install` si hace falta e iniciar Open Design con `pnpm tools-dev`. - Requisitos de entorno: Node `~24` y pnpm `10.33.x`. `nvm`/`fnm` son helpers opcionales; si usas uno, ejecuta `nvm install 24 && nvm use 24` o `fnm install 24 && fnm use 24` antes de `pnpm install`. Para arranque desktop/background, reinicios con puerto fijo y checks del dispatcher de media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), consulta [`QUICKSTART.md`](QUICKSTART.md). diff --git a/README.fr.md b/README.fr.md index ed9586057..81cb1bce2 100644 --- a/README.fr.md +++ b/README.fr.md @@ -320,8 +320,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -Lanceur Windows : compilez `OpenDesign.exe` avec les instructions de `tools/launcher/README.md`, ou téléchargez-le depuis GitHub Releases. Placez-le ensuite à la racine du dépôt et double-cliquez dessus pour lancer `pnpm install` si nécessaire, puis démarrer Open Design avec `pnpm tools-dev`. - Prérequis : Node `~24` et pnpm `10.33.x`. `nvm` / `fnm` ne sont que des aides facultatives ; si vous en utilisez un, lancez `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` avant `pnpm install`. Pour le démarrage desktop/background, les redémarrages sur ports fixes et les checks du dispatcher de génération média (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), voir [`QUICKSTART.fr.md`](QUICKSTART.fr.md). diff --git a/README.ja-JP.md b/README.ja-JP.md index 734047411..28457e210 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -320,8 +320,6 @@ pnpm tools-dev run web # tools-dev が出力した Web URL を開く ``` -Windows ランチャー: `tools/launcher/README.md` の手順で `OpenDesign.exe` を自分でビルドするか、GitHub Releases からダウンロードします。その後、リポジトリのルートに置いてダブルクリックすると、必要に応じて `pnpm install` を実行し、`pnpm tools-dev` で Open Design を起動します。 - 環境要件:Node `~24`、pnpm `10.33.x`。`nvm` / `fnm` はあくまでオプションのヘルパーです。使用する場合は `pnpm install` の前に `nvm install 24 && nvm use 24` または `fnm install 24 && fnm use 24` を実行してください。 デスクトップ / バックグラウンド起動、固定ポート再起動、メディア生成ディスパッチャの確認(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)は [`QUICKSTART.ja-JP.md`](QUICKSTART.ja-JP.md) を参照。 diff --git a/README.ko.md b/README.ko.md index 88d25cfb6..0c3c620a2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -319,8 +319,6 @@ pnpm tools-dev run web # tools-dev가 출력한 web URL을 여세요 ``` -Windows 런처: `tools/launcher/README.md`의 안내에 따라 `OpenDesign.exe`를 직접 빌드하거나 GitHub Releases에서 다운로드하세요. 그런 다음 저장소 루트에 두고 두 번 클릭하면 필요할 때 `pnpm install`을 실행한 뒤 `pnpm tools-dev`로 Open Design을 시작합니다. - 환경 요구사항: Node `~24`와 pnpm `10.33.x`. `nvm` / `fnm`은 선택적 보조 도구일 뿐입니다; 사용한다면 `pnpm install` 전에 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요. 첫 번째 로드 시: diff --git a/README.md b/README.md index c6a0fe33e..6b8985884 100644 --- a/README.md +++ b/README.md @@ -321,8 +321,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -Windows launcher: build `OpenDesign.exe` yourself with the instructions in `tools/launcher/README.md`, or download it from GitHub Releases. Then place it in the repo root and double-click it to run `pnpm install` if needed and start Open Design with `pnpm tools-dev`. - Environment requirements: Node `~24` and pnpm `10.33.x`. `nvm`/`fnm` are optional helpers only; if you use one, run `nvm install 24 && nvm use 24` or `fnm install 24 && fnm use 24` before `pnpm install`. For desktop/background startup, fixed-port restarts, and media generation dispatcher checks (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), see [`QUICKSTART.md`](QUICKSTART.md). diff --git a/README.pt-BR.md b/README.pt-BR.md index 6f7b8d930..fc4a121f1 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -320,8 +320,6 @@ pnpm tools-dev run web # open the web URL printed by tools-dev ``` -Inicializador do Windows: compile `OpenDesign.exe` com as instruções em `tools/launcher/README.md` ou baixe-o pelo GitHub Releases. Depois coloque-o na raiz do repo e dê dois cliques para executar `pnpm install` se necessário e iniciar o Open Design com `pnpm tools-dev`. - Requisitos de ambiente: Node `~24` e pnpm `10.33.x`. `nvm`/`fnm` são apenas helpers opcionais; se você usa um, rode `nvm install 24 && nvm use 24` ou `fnm install 24 && fnm use 24` antes do `pnpm install`. Para startup desktop/background, restart com porta fixa e checagens do dispatcher de geração de mídia (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), veja [`QUICKSTART.pt-BR.md`](QUICKSTART.pt-BR.md). diff --git a/README.ru.md b/README.ru.md index 7809037b2..1b51ae804 100644 --- a/README.ru.md +++ b/README.ru.md @@ -320,8 +320,6 @@ pnpm tools-dev run web # откройте web URL, который напечатает tools-dev ``` -Лаунчер Windows: соберите `OpenDesign.exe` самостоятельно по инструкции в `tools/launcher/README.md` или скачайте его из GitHub Releases. Затем поместите файл в корень репозитория и дважды щёлкните его, чтобы при необходимости выполнить `pnpm install` и запустить Open Design через `pnpm tools-dev`. - Требования к окружению: Node `~24` и pnpm `10.33.x`. `nvm`/`fnm` — только вспомогательные инструменты; если вы ими пользуетесь, выполните `nvm install 24 && nvm use 24` или `fnm install 24 && fnm use 24` перед `pnpm install`. Для desktop/background startup, перезапуска на фиксированных портах и проверки dispatcher’а media generation (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`) смотрите [`QUICKSTART.md`](QUICKSTART.md). diff --git a/README.uk.md b/README.uk.md index 3e0e67ab4..9a0cbdf6a 100644 --- a/README.uk.md +++ b/README.uk.md @@ -320,8 +320,6 @@ pnpm tools-dev run web # відкрийте URL у браузері, який виведе tools-dev ``` -Лаунчер Windows: зберіть `OpenDesign.exe` самостійно за інструкцією в `tools/launcher/README.md` або завантажте його з GitHub Releases. Потім покладіть файл у корінь репозиторію й двічі клацніть його, щоб за потреби виконати `pnpm install` і запустити Open Design через `pnpm tools-dev`. - Вимоги до середовища: Node `~24` та pnpm `10.33.x`. `nvm`/`fnm` є лише додатковими помічниками; якщо ви використовуєте один з них, запустіть `nvm install 24 && nvm use 24` або `fnm install 24 && fnm use 24` перед `pnpm install`. Для запуску desktop/background, перезапусків з фіксованими портами та перевірок диспетчера генерації медіа (`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`), див. [`QUICKSTART.md`](QUICKSTART.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 24c6a0006..38bd2987b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -319,8 +319,6 @@ pnpm tools-dev run web # 打开 tools-dev 输出的 web URL ``` -Windows 启动器:请按照 `tools/launcher/README.md` 中的说明自行构建 `OpenDesign.exe`,或从 GitHub Releases 下载。然后将它放到仓库根目录并双击;它会在需要时运行 `pnpm install`,再用 `pnpm tools-dev` 启动 Open Design。 - 环境要求:Node `~24`,pnpm `10.33.x`。`nvm` / `fnm` 只是可选辅助工具,不是项目必需步骤;如果使用它们,先执行 `nvm install 24 && nvm use 24` 或 `fnm install 24 && fnm use 24`,再运行 `pnpm install`。 桌面端/后台启动、固定端口重启,以及 media 生成派发器检查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)见 [`QUICKSTART.zh-CN.md`](QUICKSTART.zh-CN.md)。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 1c8e0f526..195e62489 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -317,8 +317,6 @@ pnpm tools-dev run web # 開啟 tools-dev 輸出的 web URL ``` -Windows 啟動器:請依照 `tools/launcher/README.md` 的說明自行建置 `OpenDesign.exe`,或從 GitHub Releases 下載。接著將它放到 repo 根目錄並雙擊;它會在需要時執行 `pnpm install`,再用 `pnpm tools-dev` 啟動 Open Design。 - 環境要求:Node `~24`,pnpm `10.33.x`。`nvm` / `fnm` 只是可選輔助工具,不是專案必需步驟;如果使用它們,先執行 `nvm install 24 && nvm use 24` 或 `fnm install 24 && fnm use 24`,再執行 `pnpm install`。 桌面版/後臺啟動、固定埠重啟,以及 media 生成派發器檢查(`OD_BIN`、`OD_DAEMON_URL`、`apps/daemon/dist/cli.js`)見 [`QUICKSTART.md`](QUICKSTART.md)。 diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 75985a8da..d8e6bf642 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -162,6 +162,7 @@ import { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); +const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH'; export function resolveProjectRoot(moduleDir: string): string { const base = path.basename(moduleDir); const daemonDir = @@ -169,7 +170,16 @@ export function resolveProjectRoot(moduleDir: string): string { return path.resolve(daemonDir, '../..'); } -export function resolveDaemonCliPath(): string { +function cleanOptionalPath(value: string | undefined): string | null { + return typeof value === 'string' && value.trim().length > 0 + ? path.resolve(value) + : null; +} + +export function resolveDaemonCliPath(env: NodeJS.ProcessEnv = process.env): string { + const configured = cleanOptionalPath(env[DAEMON_CLI_PATH_ENV]) ?? cleanOptionalPath(env.OD_BIN); + if (configured) return configured; + const packageJsonPath = require.resolve('@open-design/daemon/package.json'); return path.join(path.dirname(packageJsonPath), 'dist', 'cli.js'); } @@ -1573,7 +1583,7 @@ export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST const hints: string[] = []; if (!cliExists) { hints.push( - 'apps/daemon/dist/cli.js is missing. Run `pnpm --filter @open-design/daemon build` and refresh.', + `Open Design CLI entry is missing at ${cliPath}. Rebuild the daemon or packaged app and refresh.`, ); } if (!nodeExists) { diff --git a/apps/daemon/tests/server-paths.test.ts b/apps/daemon/tests/server-paths.test.ts index a342f9eaa..ac4a591c0 100644 --- a/apps/daemon/tests/server-paths.test.ts +++ b/apps/daemon/tests/server-paths.test.ts @@ -34,6 +34,18 @@ describe('resolveDaemonCliPath', () => { expect(resolveDaemonCliPath()).toBe(path.join(packageRoot, 'dist', 'cli.js')); }); + + it('uses the packaged daemon CLI path override before package resolution', () => { + expect(resolveDaemonCliPath({ OD_DAEMON_CLI_PATH: '/app/prebundled/daemon-cli.mjs' })).toBe( + '/app/prebundled/daemon-cli.mjs', + ); + }); + + it('uses OD_BIN as a fallback override for bundled wrapper invocations', () => { + expect(resolveDaemonCliPath({ OD_BIN: '/app/prebundled/daemon-cli.mjs' })).toBe( + '/app/prebundled/daemon-cli.mjs', + ); + }); }); describe('resolveDaemonResourceRoot', () => { diff --git a/apps/packaged/src/config.ts b/apps/packaged/src/config.ts index 76dd0065c..f655c9809 100644 --- a/apps/packaged/src/config.ts +++ b/apps/packaged/src/config.ts @@ -15,20 +15,26 @@ export type PackagedWebOutputMode = "server" | "standalone"; export type RawPackagedConfig = { appVersion?: string; + daemonCliEntryRelative?: string; + daemonSidecarEntryRelative?: string; namespace?: string; namespaceBaseRoot?: string; nodeCommandRelative?: string; resourceRoot?: string; + webSidecarEntryRelative?: string; webStandaloneRoot?: string; webOutputMode?: string; }; export type PackagedConfig = { appVersion: string | null; + daemonCliEntry: string | null; + daemonSidecarEntry: string | null; namespace: string; namespaceBaseRoot: string; nodeCommand: string | null; resourceRoot: string; + webSidecarEntry: string | null; webStandaloneRoot: string | null; webOutputMode: PackagedWebOutputMode; }; @@ -98,6 +104,16 @@ function resolvePackagedWebStandaloneRoot( return join(process.resourcesPath, "open-design-web-standalone"); } +async function resolvePackagedRelativeEntry(value: string | undefined): Promise { + const cleaned = cleanOptionalString(value); + if (cleaned == null) return null; + const entry = join(process.resourcesPath, cleaned); + if (!(await pathExists(entry))) { + throw new Error(`configured packaged entry not found at ${entry}`); + } + return entry; +} + export async function readPackagedConfig(): Promise { const raw = await readRawPackagedConfig(); const namespace = normalizeNamespace( @@ -124,13 +140,19 @@ export async function readPackagedConfig(): Promise { ? process.env[PACKAGED_WEB_STANDALONE_ROOT_ENV] ?? raw.webStandaloneRoot : raw.webStandaloneRoot, ); + const daemonCliEntry = await resolvePackagedRelativeEntry(raw.daemonCliEntryRelative); + const daemonSidecarEntry = await resolvePackagedRelativeEntry(raw.daemonSidecarEntryRelative); + const webSidecarEntry = await resolvePackagedRelativeEntry(raw.webSidecarEntryRelative); return { appVersion: cleanOptionalString(raw.appVersion), + daemonCliEntry, + daemonSidecarEntry, namespace, namespaceBaseRoot, nodeCommand, resourceRoot, + webSidecarEntry, webStandaloneRoot, webOutputMode, }; diff --git a/apps/packaged/src/headless.ts b/apps/packaged/src/headless.ts index 26d901f2a..243be159e 100644 --- a/apps/packaged/src/headless.ts +++ b/apps/packaged/src/headless.ts @@ -54,10 +54,13 @@ function resolveHeadlessConfig(): PackagedConfig { return { appVersion: null, + daemonCliEntry: null, + daemonSidecarEntry: null, namespace, namespaceBaseRoot, nodeCommand: null, resourceRoot, + webSidecarEntry: null, webStandaloneRoot: null, webOutputMode: "server", }; @@ -101,7 +104,10 @@ async function main(): Promise { const sidecars = await startPackagedSidecars(runtime, paths, { appVersion: config.appVersion, + daemonCliEntry: config.daemonCliEntry, + daemonSidecarEntry: config.daemonSidecarEntry, nodeCommand: config.nodeCommand, + webSidecarEntry: config.webSidecarEntry, webStandaloneRoot: config.webStandaloneRoot, webOutputMode: config.webOutputMode, }); diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index 887a330fe..99dcc68f7 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -80,7 +80,10 @@ async function main(): Promise { const sidecars = await startPackagedSidecars(runtime, paths, { appVersion: config.appVersion, + daemonCliEntry: config.daemonCliEntry, + daemonSidecarEntry: config.daemonSidecarEntry, nodeCommand: config.nodeCommand, + webSidecarEntry: config.webSidecarEntry, webStandaloneRoot: config.webStandaloneRoot, webOutputMode: config.webOutputMode, }); diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index 90f995cca..e0005f3e8 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -273,7 +273,10 @@ export async function startPackagedSidecars( paths: PackagedNamespacePaths, options: { appVersion: string | null; + daemonCliEntry: string | null; + daemonSidecarEntry: string | null; nodeCommand: string | null; + webSidecarEntry: string | null; webStandaloneRoot: string | null; webOutputMode: PackagedWebOutputMode; }, @@ -292,9 +295,10 @@ export async function startPackagedSidecars( try { const daemon = await spawnSidecarChild({ app: APP_KEYS.DAEMON, - entryPath: resolveSidecarEntry("@open-design/daemon", "sidecar"), + entryPath: options.daemonSidecarEntry ?? resolveSidecarEntry("@open-design/daemon", "sidecar"), env: { [SIDECAR_ENV.DAEMON_PORT]: "0", + ...(options.daemonCliEntry == null ? {} : { [SIDECAR_ENV.DAEMON_CLI_PATH]: options.daemonCliEntry }), // Packaged daemon managed paths are deliberately delivered through // the sidecar launch environment. The daemon may keep its own default // fallback, but packaged runtime must not rely on path inference from @@ -331,7 +335,7 @@ export async function startPackagedSidecars( const web = await spawnSidecarChild({ app: APP_KEYS.WEB, - entryPath: resolveSidecarEntry("@open-design/web", "sidecar"), + entryPath: options.webSidecarEntry ?? resolveSidecarEntry("@open-design/web", "sidecar"), env: { [SIDECAR_ENV.DAEMON_PORT]: extractPort(daemonStatus.url), [SIDECAR_ENV.WEB_PORT]: "0", diff --git a/apps/web/sidecar/server.ts b/apps/web/sidecar/server.ts index 12879c2d3..045c2560a 100644 --- a/apps/web/sidecar/server.ts +++ b/apps/web/sidecar/server.ts @@ -168,10 +168,9 @@ function resolveConfiguredStandaloneRoot(): string | null { } export function resolveStandaloneServerEntry( - webRoot: string = resolveWebRoot(), + webRoot: string | null = resolveWebRoot(), standaloneRoot: string | null = resolveConfiguredStandaloneRoot(), ): string | null { - const distDir = resolveWebDistDir(webRoot); const configuredRoot = standaloneRoot == null || standaloneRoot.length === 0 ? null : isAbsolute(standaloneRoot) @@ -184,8 +183,12 @@ export function resolveStandaloneServerEntry( join(configuredRoot, "apps", "web", "server.js"), join(configuredRoot, "server.js"), ]), - join(distDir, "standalone", "apps", "web", "server.js"), - join(distDir, "standalone", "server.js"), + ...(webRoot == null + ? [] + : [ + join(resolveWebDistDir(webRoot), "standalone", "apps", "web", "server.js"), + join(resolveWebDistDir(webRoot), "standalone", "server.js"), + ]), ]; return candidates.find((candidate) => existsSync(candidate)) ?? null; @@ -414,10 +417,14 @@ async function waitForStandaloneBackendReady( throw new Error(`timed out after ${timeoutMs}ms waiting for standalone Next.js server at ${origin}; override with ${STANDALONE_STARTUP_TIMEOUT_ENV}`); } -async function startStandaloneBackend(webRoot: string): Promise { +async function startStandaloneBackend(webRoot: string | null): Promise { const entryPath = resolveStandaloneServerEntry(webRoot); if (entryPath == null) { - throw new Error(`missing Next.js standalone server under ${resolveWebDistDir(webRoot)}; rebuild with ${WEB_OUTPUT_MODE_ENV}=standalone`); + throw new Error( + webRoot == null + ? `missing Next.js standalone server under ${WEB_STANDALONE_ROOT_ENV}; configure ${WEB_STANDALONE_ROOT_ENV} or install @open-design/web` + : `missing Next.js standalone server under ${resolveWebDistDir(webRoot)}; rebuild with ${WEB_OUTPUT_MODE_ENV}=standalone`, + ); } const port = await reserveTcpPort(STANDALONE_BACKEND_HOST); @@ -625,7 +632,7 @@ async function startRegularNextSidecar( async function startStandaloneNextSidecar( runtime: SidecarRuntimeContext, - webRoot: string, + webRoot: string | null, ): Promise { const daemonOrigin = resolveDaemonOrigin(); const backend = await startStandaloneBackend(webRoot); @@ -653,10 +660,11 @@ async function startStandaloneNextSidecar( } export async function startWebSidecar(runtime: SidecarRuntimeContext): Promise { - const webRoot = resolveWebRoot(); if (shouldUseStandaloneOutput(runtime)) { + const webRoot = resolveConfiguredStandaloneRoot() == null ? resolveWebRoot() : null; return await startStandaloneNextSidecar(runtime, webRoot); } + const webRoot = resolveWebRoot(); return await startRegularNextSidecar(runtime, webRoot); } diff --git a/apps/web/tests/sidecar-proxy.test.ts b/apps/web/tests/sidecar-proxy.test.ts index 2d7588e7e..88c7f70b9 100644 --- a/apps/web/tests/sidecar-proxy.test.ts +++ b/apps/web/tests/sidecar-proxy.test.ts @@ -85,6 +85,20 @@ describe('resolveStandaloneServerEntry', () => { await rm(copiedRoot, { force: true, recursive: true }); } }); + + it('can resolve a copied standalone resource without a web package root', async () => { + const copiedRoot = await mkdtemp(join(tmpdir(), 'open-design-web-copied-only-')); + const copiedWebRoot = join(copiedRoot, 'apps', 'web'); + + try { + await mkdir(copiedWebRoot, { recursive: true }); + await writeFile(join(copiedWebRoot, 'server.js'), '', 'utf8'); + + expect(resolveStandaloneServerEntry(null, copiedRoot)).toBe(join(copiedWebRoot, 'server.js')); + } finally { + await rm(copiedRoot, { force: true, recursive: true }); + } + }); }); describe('createStandaloneServerArgs', () => { diff --git a/packages/sidecar-proto/src/index.ts b/packages/sidecar-proto/src/index.ts index 05f648155..5218ac11f 100644 --- a/packages/sidecar-proto/src/index.ts +++ b/packages/sidecar-proto/src/index.ts @@ -23,6 +23,7 @@ export type SidecarSource = (typeof SIDECAR_SOURCES)[keyof typeof SIDECAR_SOURCE export const SIDECAR_ENV = Object.freeze({ BASE: "OD_SIDECAR_BASE", + DAEMON_CLI_PATH: "OD_DAEMON_CLI_PATH", DAEMON_PORT: "OD_PORT", IPC_BASE: "OD_SIDECAR_IPC_BASE", IPC_PATH: "OD_SIDECAR_IPC_PATH", diff --git a/scripts/guard.ts b/scripts/guard.ts index 097eb324b..b061414cb 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -338,11 +338,60 @@ async function checkWebTestLayout(): Promise { return true; } +const toolsRootAllowlist = new Map([ + // Keep top-level tools intentionally small. `tools/launcher` was an incoming + // Windows shim experiment from PR #683 and is not an active repo boundary. + ["AGENTS.md", "file"], + ["dev", "directory"], + ["pack", "directory"], +]); + +async function checkToolsLayout(): Promise { + const toolsRoot = path.join(repoRoot, "tools"); + const entries = await readdir(toolsRoot, { withFileTypes: true }); + const seen = new Set(); + const violations: string[] = []; + + for (const entry of entries) { + const expected = toolsRootAllowlist.get(entry.name); + const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`; + + if (expected == null) { + violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, and pack/`); + continue; + } + + seen.add(entry.name); + if (expected === "directory" && !entry.isDirectory()) { + violations.push(`${repositoryPath} -> expected tools/${entry.name}/ to be a directory`); + } + if (expected === "file" && !entry.isFile()) { + violations.push(`${repositoryPath} -> expected tools/${entry.name} to be a file`); + } + } + + for (const [entryName, expected] of toolsRootAllowlist) { + if (!seen.has(entryName)) { + violations.push(`tools/${entryName}${expected === "directory" ? "/" : ""} -> required tools boundary is missing`); + } + } + + if (violations.length > 0) { + console.error("Tools layout violations found:"); + for (const violation of violations) console.error(`- ${violation}`); + return false; + } + + console.log("Tools layout check passed: tools/ top-level entries match the active boundary allowlist."); + return true; +} + const checks: GuardCheck[] = [ { name: "residual JavaScript", run: checkResidualJavaScript }, { name: "test layout", run: checkTestLayout }, { name: "e2e layout", run: checkE2eLayout }, { name: "web test layout", run: checkWebTestLayout }, + { name: "tools layout", run: checkToolsLayout }, ]; const results: boolean[] = []; diff --git a/scripts/release-beta.ts b/scripts/release-beta.ts index 0c26de2a0..82b03bfb5 100644 --- a/scripts/release-beta.ts +++ b/scripts/release-beta.ts @@ -1,30 +1,15 @@ import { execFile as execFileCallback } from "node:child_process"; import { appendFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { get as httpsGet } from "node:https"; import { join } from "node:path"; import { promisify } from "node:util"; const execFile = promisify(execFileCallback); -const BETA_TAG = "open-design-beta"; const stableVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/; const stableTagPattern = /^open-design-v(\d+\.\d+\.\d+)$/; const betaVersionPattern = /^(\d+\.\d+\.\d+)-beta\.(\d+)$/; -const betaTagPattern = /^open-design-v(\d+\.\d+\.\d+)-beta\.(\d+)(?:\.unsigned)?$/; - -type GitHubReleaseAsset = { - id?: number; - name?: string; -}; - -type GitHubRelease = { - assets?: GitHubReleaseAsset[]; - body?: string | null; - draft?: boolean; - name?: string | null; - prerelease?: boolean; - tag_name?: string; -}; type ParsedStableVersion = { parsed: [number, number, number]; @@ -37,6 +22,10 @@ type ParsedBetaVersion = { betaVersion: string; }; +type ParsedBetaMetadata = ParsedBetaVersion & { + source: "metadata-json"; +}; + function fail(message: string): never { console.error(`[release-beta] ${message}`); process.exit(1); @@ -66,54 +55,83 @@ function compareVersions(left: [number, number, number], right: [number, number, return 0; } -function extractStableVersion(release: GitHubRelease): ParsedStableVersion | null { - const candidates = [release.tag_name, release.name].filter((value): value is string => typeof value === "string"); +function extractStableVersionFromTag(tag: string): ParsedStableVersion | null { + const match = stableTagPattern.exec(tag); + if (match?.[1] == null) return null; - for (const candidate of candidates) { - const tagMatch = stableTagPattern.exec(candidate); - const value = tagMatch?.[1] ?? candidate.match(/\b(\d+\.\d+\.\d+)\b/)?.[1]; - if (value == null) continue; - - const parsed = parseStableVersion(value); - if (parsed != null) return { parsed, value }; - } - - return null; + const parsed = parseStableVersion(match[1]); + return parsed == null ? null : { parsed, value: match[1] }; } function parseBetaParts(baseVersion: string, betaNumber: string): ParsedBetaVersion { + const parsedBetaNumber = Number(betaNumber); + if (!Number.isSafeInteger(parsedBetaNumber) || parsedBetaNumber < 1) { + fail(`invalid beta number in latest beta metadata: ${betaNumber}`); + } + return { baseVersion, - betaNumber: Number(betaNumber), + betaNumber: parsedBetaNumber, betaVersion: `${baseVersion}-beta.${betaNumber}`, }; } -function extractBetaVersion(release: GitHubRelease): ParsedBetaVersion | null { - const tagMatch = typeof release.tag_name === "string" ? betaTagPattern.exec(release.tag_name) : null; - if (tagMatch?.[1] != null && tagMatch[2] != null) { - return parseBetaParts(tagMatch[1], tagMatch[2]); - } - - const candidates = [release.name, release.body].filter((value): value is string => typeof value === "string"); - for (const candidate of candidates) { - const match = candidate.match(/(\d+\.\d+\.\d+)-beta\.(\d+)/); - if (match?.[1] != null && match[2] != null) { - return parseBetaParts(match[1], match[2]); - } - } - - return null; +function readStringField(record: Record, field: string): string | null { + const value = record[field]; + return typeof value === "string" && value.length > 0 ? value : null; } -function extractBetaVersionFromLatestMacYml(value: string): ParsedBetaVersion | null { - const match = value.match(/^version:\s*["']?([^"'\n]+)["']?\s*$/m); - if (match?.[1] == null) return null; +function readNumberField(record: Record, field: string): number | null { + const value = record[field]; + return typeof value === "number" && Number.isSafeInteger(value) ? value : null; +} - const betaMatch = betaVersionPattern.exec(match[1]); - if (betaMatch?.[1] == null || betaMatch[2] == null) return null; +function parseBetaVersion(value: string, sourceName: string): ParsedBetaVersion { + const match = betaVersionPattern.exec(value); + if (match?.[1] == null || match[2] == null) { + fail(`${sourceName} betaVersion must be x.y.z-beta.N; got ${value}`); + } + return parseBetaParts(match[1], match[2]); +} - return parseBetaParts(betaMatch[1], betaMatch[2]); +function parseBetaMetadataJson(value: string): ParsedBetaMetadata { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + fail(`R2 beta metadata.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { + fail("R2 beta metadata.json must be a JSON object"); + } + + const record = parsed as Record; + const betaVersion = readStringField(record, "betaVersion"); + const betaNumber = readNumberField(record, "betaNumber"); + const baseVersion = readStringField(record, "baseVersion"); + + if (betaVersion != null) { + const beta = parseBetaVersion(betaVersion, "R2 beta metadata.json"); + if (baseVersion != null && baseVersion !== beta.baseVersion) { + fail(`R2 beta metadata.json baseVersion ${baseVersion} does not match betaVersion ${beta.betaVersion}`); + } + if (betaNumber != null && betaNumber !== beta.betaNumber) { + fail(`R2 beta metadata.json betaNumber ${betaNumber} does not match betaVersion ${beta.betaVersion}`); + } + return { ...beta, source: "metadata-json" }; + } + + if (baseVersion == null || betaNumber == null) { + fail("R2 beta metadata.json must include betaVersion or baseVersion+betaNumber"); + } + + const parsedBase = parseStableVersion(baseVersion); + if (parsedBase == null) { + fail(`R2 beta metadata.json baseVersion must be x.y.z; got ${baseVersion}`); + } + + return { ...parseBetaParts(baseVersion, String(betaNumber)), source: "metadata-json" }; } async function readPackagedVersion(): Promise { @@ -131,25 +149,83 @@ async function readPackagedVersion(): Promise { return packageJson.version; } -async function fetchReleases(repository: string): Promise { - const releases: GitHubRelease[] = []; - for (let page = 1; ; page += 1) { - const { stdout } = await execFile("gh", ["api", `repos/${repository}/releases?per_page=100&page=${page}`]); - const batch = JSON.parse(stdout) as GitHubRelease[]; - if (batch.length === 0) break; - releases.push(...batch); - } - return releases; +async function fetchGitTags(pattern: string): Promise { + const { stdout } = await execFile("git", ["tag", "--list", pattern]); + return stdout + .split("\n") + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0); } -async function fetchReleaseAssetText(repository: string, assetId: number): Promise { - const { stdout } = await execFile("gh", [ - "api", - `repos/${repository}/releases/assets/${assetId}`, - "--header", - "Accept: application/octet-stream", - ]); - return stdout; +function fetchOptionalHttpsText(url: string, redirectCount = 0): Promise { + return new Promise((resolvePromise, reject) => { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + reject(new Error(`expected HTTPS URL for beta feed lookup: ${parsed.protocol}`)); + return; + } + + const request = httpsGet( + parsed, + { + headers: { + "Cache-Control": "no-cache", + }, + }, + (response) => { + const statusCode = response.statusCode ?? 0; + if (statusCode === 404) { + response.resume(); + resolvePromise(null); + return; + } + + const location = response.headers.location; + if (statusCode >= 300 && statusCode < 400 && typeof location === "string") { + response.resume(); + if (redirectCount >= 3) { + reject(new Error("too many redirects while reading beta feed")); + return; + } + const nextUrl = new URL(location, parsed).toString(); + fetchOptionalHttpsText(nextUrl, redirectCount + 1).then(resolvePromise, reject); + return; + } + + if (statusCode < 200 || statusCode >= 300) { + response.resume(); + reject(new Error(`beta feed request failed with HTTP ${statusCode}`)); + return; + } + + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on("end", () => { + resolvePromise(Buffer.concat(chunks).toString("utf8")); + }); + }, + ); + + request.setTimeout(10_000, () => { + request.destroy(new Error("timed out while reading beta feed")); + }); + request.on("error", reject); + }); +} + +function validateHttpsUrl(value: string, name: string): void { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + fail(`${name} must be an HTTPS URL; got ${value}`); + } + + if (parsed.protocol !== "https:") { + fail(`${name} must be an HTTPS URL; got ${value}`); + } } function setOutput(name: string, value: string): void { @@ -158,17 +234,14 @@ function setOutput(name: string, value: string): void { appendFileSync(outputPath, `${name}=${value}\n`); } -const repository = process.env.GITHUB_REPOSITORY ?? fail("GITHUB_REPOSITORY is required"); const signed = process.env.OPEN_DESIGN_RELEASE_SIGNED !== "false"; const packagedVersion = await readPackagedVersion(); const packagedParsed = parseStableVersion(packagedVersion) ?? fail(`invalid packaged version: ${packagedVersion}`); -const releases = await fetchReleases(repository); +const tags = await fetchGitTags("open-design-v*"); let latestStable: ParsedStableVersion | null = null; -for (const release of releases) { - if (release.draft === true || release.prerelease === true) continue; - - const stableVersion = extractStableVersion(release); +for (const tag of tags) { + const stableVersion = extractStableVersionFromTag(tag); if (stableVersion == null) continue; if (latestStable == null || compareVersions(stableVersion.parsed, latestStable.parsed) > 0) { @@ -180,23 +253,38 @@ if (latestStable != null && compareVersions(packagedParsed, latestStable.parsed) fail(`packaged base version ${packagedVersion} must be strictly greater than latest stable ${latestStable.value}`); } -const betaCandidates: ParsedBetaVersion[] = []; -for (const release of releases) { - const beta = extractBetaVersion(release); - if (beta != null) betaCandidates.push(beta); -} - -const existingBetaRelease = releases.find((release) => release.tag_name === BETA_TAG); -const latestMacAsset = existingBetaRelease?.assets?.find((asset) => asset.name === "latest-mac.yml"); -if (latestMacAsset?.id != null) { - const beta = extractBetaVersionFromLatestMacYml(await fetchReleaseAssetText(repository, latestMacAsset.id)); - if (beta != null) betaCandidates.push(beta); +const metadataUrl = process.env.OPEN_DESIGN_BETA_METADATA_URL; +if (metadataUrl == null || metadataUrl.length === 0) { + fail("OPEN_DESIGN_BETA_METADATA_URL is required"); } +validateHttpsUrl(metadataUrl, "OPEN_DESIGN_BETA_METADATA_URL"); let betaNumber = 1; -for (const beta of betaCandidates) { +let latestBeta: ParsedBetaVersion | null = null; +let stateSource = "R2 metadata.json"; +const latestMetadataJson = await fetchOptionalHttpsText(metadataUrl); +if (latestMetadataJson == null) { + // Only HTTP 404 reaches this branch; other fetch failures throw above. This + // is an intentional cold-start/reset behavior for a missing beta metadata + // object, not a fallback to any updater feed or GitHub release state. + latestBeta = { + baseVersion: packagedVersion, + betaNumber: 0, + betaVersion: `${packagedVersion}-beta.0`, + }; + stateSource = "missing R2 metadata.json fallback beta.0"; + console.log("[release-beta] R2 beta metadata.json: not found; using beta.0 fallback"); +} else { + latestBeta = parseBetaMetadataJson(latestMetadataJson); + console.log(`[release-beta] R2 beta metadata.json version: ${latestBeta.betaVersion}`); +} + +if (latestBeta != null) { + const beta = latestBeta; const existingBase = parseStableVersion(beta.baseVersion); - if (existingBase == null) continue; + if (existingBase == null) { + fail(`invalid beta base version in ${stateSource}: ${beta.baseVersion}`); + } const ordering = compareVersions(packagedParsed, existingBase); if (ordering < 0) { @@ -204,13 +292,12 @@ for (const beta of betaCandidates) { } if (ordering === 0) { - betaNumber = Math.max(betaNumber, beta.betaNumber + 1); + betaNumber = beta.betaNumber + 1; } } const betaVersion = `${packagedVersion}-beta.${betaNumber}`; const unsignedSuffix = signed ? "" : ".unsigned"; -const versionTag = `open-design-v${betaVersion}${unsignedSuffix}`; const branch = process.env.GITHUB_REF_NAME ?? ""; const commit = process.env.GITHUB_SHA ?? ""; const releaseName = `Open Design Beta ${betaVersion}${signed ? "" : " (unsigned)"}`; @@ -219,18 +306,17 @@ console.log(`[release-beta] channel: beta`); console.log(`[release-beta] base version: ${packagedVersion}`); console.log(`[release-beta] beta version: ${betaVersion}`); console.log(`[release-beta] signed: ${signed ? "true" : "false"}`); -console.log(`[release-beta] fixed beta tag: ${BETA_TAG}`); -console.log(`[release-beta] immutable beta tag: ${versionTag}`); +console.log(`[release-beta] beta state source: ${stateSource}`); if (latestStable != null) console.log(`[release-beta] latest stable: ${latestStable.value}`); +if (latestBeta != null) console.log(`[release-beta] latest beta: ${latestBeta.betaVersion}`); setOutput("asset_version_suffix", unsignedSuffix); setOutput("base_version", packagedVersion); setOutput("beta_number", String(betaNumber)); -setOutput("beta_tag", BETA_TAG); setOutput("beta_version", betaVersion); setOutput("branch", branch); setOutput("commit", commit); setOutput("latest_stable", latestStable?.value ?? ""); setOutput("release_name", releaseName); setOutput("signed", signed ? "true" : "false"); -setOutput("version_tag", versionTag); +setOutput("state_source", stateSource); diff --git a/tools/launcher/OpenDesignLauncher.cs b/tools/launcher/OpenDesignLauncher.cs deleted file mode 100644 index 9a057091c..000000000 --- a/tools/launcher/OpenDesignLauncher.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; - -namespace OpenDesignLauncher -{ - internal static class Program - { - private static int Main() - { - Console.Title = "Open Design Launcher"; - Console.WriteLine("Open Design launcher"); - Console.WriteLine(); - - string repoRoot = FindRepoRoot(AppDomain.CurrentDomain.BaseDirectory); - if (repoRoot == null) - { - Console.Error.WriteLine("Could not find the Open Design repository root."); - Console.Error.WriteLine("Place OpenDesign.exe in the repository root next to package.json."); - Pause(); - return 1; - } - - Console.WriteLine("Repository: " + repoRoot); - - if (!Directory.Exists(Path.Combine(repoRoot, "node_modules", ".pnpm"))) - { - Console.WriteLine("Dependencies are missing. Running pnpm install first..."); - // Requires corepack (bundled with Node 16.9+) so future maintainers know the dependency. - int installExit = RunCommand(repoRoot, "corepack pnpm install"); - if (installExit != 0) - { - Console.Error.WriteLine("pnpm install failed with exit code " + installExit + "."); - Pause(); - return installExit; - } - } - - Console.WriteLine("Starting Open Design with pnpm tools-dev..."); - Console.WriteLine(); - int exitCode = RunCommand(repoRoot, "corepack pnpm tools-dev"); - if (exitCode != 0) - { - Console.Error.WriteLine(); - Console.Error.WriteLine("Open Design exited with code " + exitCode + "."); - Pause(); - } - else - { - Console.WriteLine(); - Console.WriteLine("Open Design command completed. Press any key to close this window."); - Console.ReadKey(true); - } - - return exitCode; - } - - private static string FindRepoRoot(string startDirectory) - { - DirectoryInfo current = new DirectoryInfo(startDirectory); - while (current != null) - { - string packageJson = Path.Combine(current.FullName, "package.json"); - string workspace = Path.Combine(current.FullName, "pnpm-workspace.yaml"); - if (File.Exists(packageJson) && File.Exists(workspace)) - { - return current.FullName; - } - current = current.Parent; - } - return null; - } - - private static int RunCommand(string workingDirectory, string command) - { - ProcessStartInfo info = new ProcessStartInfo(); - info.FileName = "cmd.exe"; - info.Arguments = "/d /c \"" + command + "\""; - info.WorkingDirectory = workingDirectory; - info.UseShellExecute = false; - - using (Process process = Process.Start(info)) - { - process.WaitForExit(); - return process.ExitCode; - } - } - - private static void Pause() - { - Console.WriteLine("Press any key to close this window."); - Console.ReadKey(true); - } - } -} diff --git a/tools/launcher/README.md b/tools/launcher/README.md deleted file mode 100644 index 3b3a86158..000000000 --- a/tools/launcher/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Open Design Windows Launcher - -`OpenDesignLauncher.cs` builds a small Windows console executable that starts the -local development app without typing the normal commands by hand. - -The compiled `OpenDesign.exe` is intentionally not committed to git. Build it -locally when you want the shortcut, or download a trusted build from GitHub -Releases if the project publishes one. - -## Build - -From the repository root on Windows: - -```powershell -C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe /target:exe /out:OpenDesign.exe /win32icon:tools\pack\resources\win\icon.ico tools\launcher\OpenDesignLauncher.cs -``` - -If your Windows installation only has the 32-bit .NET Framework compiler, use: - -```powershell -C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /target:exe /out:OpenDesign.exe /win32icon:tools\pack\resources\win\icon.ico tools\launcher\OpenDesignLauncher.cs -``` - -## Run - -Place the built `OpenDesign.exe` in the repository root next to `package.json`, -then double-click it. - -The launcher checks for dependencies and runs: - -```powershell -corepack pnpm install -corepack pnpm tools-dev -``` diff --git a/tools/pack/resources/web-standalone-after-pack.cjs b/tools/pack/resources/web-standalone-after-pack.cjs index acc0ff248..c62cabd61 100644 --- a/tools/pack/resources/web-standalone-after-pack.cjs +++ b/tools/pack/resources/web-standalone-after-pack.cjs @@ -1,4 +1,4 @@ -const { access, cp, lstat, mkdir, readFile, readdir, rm, stat, symlink, writeFile } = require("node:fs/promises"); +const { access, cp, lstat, mkdir, readFile, readlink, readdir, realpath, rm, stat, symlink, writeFile } = require("node:fs/promises"); const { createRequire } = require("node:module"); const path = require("node:path"); @@ -39,6 +39,11 @@ function isWithin(parent, child) { return relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +async function isWithinPhysicalPath(parent, child) { + const [realParent, realChild] = await Promise.all([realpath(parent), realpath(child)]); + return isWithin(realParent, realChild); +} + async function pathExists(filePath) { try { await access(filePath); @@ -228,6 +233,14 @@ async function installStandaloneResource(config, resourcesRoot, platformName) { await copyRequired(path.join(sourceWebRoot, ".next"), path.join(destinationWebRoot, ".next")); const copiedStatic = await copyOptional(config.webStaticSourceRoot, path.join(destinationWebRoot, ".next", "static")); const copiedPublic = await copyOptional(config.webPublicSourceRoot, path.join(destinationWebRoot, "public")); + const rewrittenSymlinks = platformName === "win32" + ? [] + : await rewriteCopiedStandaloneSymlinks({ + destinationRoot, + destinationWebRoot, + sourceWebRoot, + standaloneSourceRoot: config.standaloneSourceRoot, + }); return { copiedNestedNodeModules, @@ -236,10 +249,83 @@ async function installStandaloneResource(config, resourcesRoot, platformName) { destinationRoot, destinationWebRoot, linkedHoistEntries, + rewrittenSymlinks, sourceWebRoot, }; } +async function rewriteCopiedStandaloneSymlinks(options) { + const mappings = [ + { + destinationRoot: path.join(options.destinationWebRoot, "node_modules"), + sourceRoot: path.join(options.sourceWebRoot, "node_modules"), + }, + { + destinationRoot: path.join(options.destinationRoot, "node_modules"), + sourceRoot: path.join(options.standaloneSourceRoot, "node_modules"), + }, + { + destinationRoot: options.destinationWebRoot, + sourceRoot: options.sourceWebRoot, + }, + { + destinationRoot: options.destinationRoot, + sourceRoot: options.standaloneSourceRoot, + }, + ]; + const rewrittenSymlinks = []; + + function mapPath(pathToMap, fromKey, toKey) { + for (const mapping of mappings) { + if (!isWithin(mapping[fromKey], pathToMap)) continue; + return path.join(mapping[toKey], path.relative(mapping[fromKey], pathToMap)); + } + return null; + } + + async function visit(current) { + let metadata; + try { + metadata = await lstat(current); + } catch { + return; + } + + if (metadata.isSymbolicLink()) { + const copiedSourcePath = mapPath(current, "destinationRoot", "sourceRoot"); + if (copiedSourcePath == null) return; + + const currentTarget = await readlink(current); + const sourceTarget = path.resolve(path.dirname(copiedSourcePath), currentTarget); + const destinationTarget = mapPath(sourceTarget, "sourceRoot", "destinationRoot"); + // External source-tree symlinks are not rewritten; the closure audit below + // reports them as externalSymlink and fails the package instead. + if (destinationTarget == null) return; + + const nextTarget = path.relative(path.dirname(current), destinationTarget) || "."; + if (nextTarget === currentTarget) return; + + await rm(current, { force: true, recursive: true }); + await symlink(nextTarget, current); + rewrittenSymlinks.push({ + path: current, + target: nextTarget, + }); + return; + } + + if (!metadata.isDirectory()) return; + + const entries = await readdir(current, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + await visit(path.join(current, entry.name)); + } + } + + await visit(options.destinationRoot); + return rewrittenSymlinks; +} + async function removePathAndRecord(targetPath, reason, removedPaths) { const existed = await pathExists(targetPath); const bytes = await sizePathBytes(targetPath); @@ -409,7 +495,12 @@ function isForbiddenCopiedEntry(relativePath, platformName) { ); } -async function collectClosureStats(root, current = root, stats = { brokenSymlinks: [], forbiddenEntries: [], symlinks: 0 }, platformName = process.platform) { +async function collectClosureStats( + root, + current = root, + stats = { brokenSymlinks: [], externalSymlinks: [], forbiddenEntries: [], symlinks: 0 }, + platformName = process.platform, +) { let metadata; try { metadata = await lstat(current); @@ -426,6 +517,9 @@ async function collectClosureStats(root, current = root, stats = { brokenSymlink stats.symlinks += 1; try { await stat(current); + if (!(await isWithinPhysicalPath(root, current))) { + stats.externalSymlinks.push(relativePath.split(path.sep).join("/")); + } } catch { stats.brokenSymlinks.push(relativePath.split(path.sep).join("/")); } @@ -441,8 +535,8 @@ async function collectClosureStats(root, current = root, stats = { brokenSymlink return stats; } -function assertResolvedInside(root, moduleName, resolvedPath) { - if (!isWithin(root, resolvedPath)) { +async function assertResolvedInside(root, moduleName, resolvedPath) { + if (!(await isWithinPhysicalPath(root, resolvedPath))) { throw new Error(`[tools-pack web-standalone] ${moduleName} resolved outside copied standalone: ${resolvedPath}`); } } @@ -466,7 +560,7 @@ async function auditCopiedStandalone(config, installResult, platformName) { const resolvedModules = {}; for (const moduleName of REQUIRED_MODULES) { const resolvedPath = localRequire.resolve(moduleName); - assertResolvedInside(installResult.destinationRoot, moduleName, resolvedPath); + await assertResolvedInside(installResult.destinationRoot, moduleName, resolvedPath); resolvedModules[moduleName] = resolvedPath; } @@ -474,6 +568,9 @@ async function auditCopiedStandalone(config, installResult, platformName) { if (closureStats.brokenSymlinks.length > 0) { throw new Error(`[tools-pack web-standalone] copied standalone has broken symlinks: ${closureStats.brokenSymlinks.join(", ")}`); } + if (closureStats.externalSymlinks.length > 0) { + throw new Error(`[tools-pack web-standalone] copied standalone has external symlinks: ${closureStats.externalSymlinks.join(", ")}`); + } if (closureStats.forbiddenEntries.length > 0) { throw new Error(`[tools-pack web-standalone] copied standalone has forbidden entries: ${closureStats.forbiddenEntries.join(", ")}`); } @@ -483,6 +580,7 @@ async function auditCopiedStandalone(config, installResult, platformName) { bytes: await sizePathBytes(installResult.destinationRoot), destinationRoot: installResult.destinationRoot, destinationWebRoot: installResult.destinationWebRoot, + externalSymlinks: closureStats.externalSymlinks, forbiddenEntries: closureStats.forbiddenEntries, nodeModulesBytes: await sizePathBytes(nodeModulesRoot), resolvedModules, @@ -519,7 +617,7 @@ async function auditCopiedStandaloneNextDedupe(installResult, platformName) { } const resolvedNextPackagePath = createRequire(serverPath).resolve("next/package.json"); - if (!isWithin(retainedNextRoot, resolvedNextPackagePath)) { + if (!(await isWithinPhysicalPath(retainedNextRoot, resolvedNextPackagePath))) { throw new Error( `[tools-pack web-standalone] copied standalone next resolved outside retained app-local next: ${resolvedNextPackagePath}`, ); diff --git a/tools/pack/src/index.ts b/tools/pack/src/index.ts index e363e8233..576f309f7 100644 --- a/tools/pack/src/index.ts +++ b/tools/pack/src/index.ts @@ -11,7 +11,7 @@ import { startPackedMacApp, stopPackedMacApp, uninstallPackedMacApp, -} from "./mac.js"; +} from "./mac/index.js"; import { cleanupPackedWinNamespace, installPackedWinApp, diff --git a/tools/pack/src/linux.ts b/tools/pack/src/linux.ts index e0cce2c10..d3b91b135 100644 --- a/tools/pack/src/linux.ts +++ b/tools/pack/src/linux.ts @@ -891,7 +891,7 @@ export async function stopPackedLinuxApp(config: ToolPackConfig): Promise options.forbiddenInputs.some((forbidden) => input.includes(forbidden))); +} + +export async function assertMacPrebundleMetafile(options: { + metafilePath: string; + policyName: MacPrebundlePolicyName; +}): Promise { + const policy = MAC_PREBUNDLE_POLICIES[options.policyName]; + const metafile = JSON.parse(await readFile(options.metafilePath, "utf8")) as { inputs?: Record }; + const matched = findForbiddenMacPrebundleInputs({ + forbiddenInputs: policy.forbiddenInputs, + inputs: Object.keys(metafile.inputs ?? {}), + }); + if (matched.length > 0) { + throw new Error(`${policy.label} prebundle included forbidden inputs: ${matched.join(", ")}`); + } +} + +export function renderMacPackagedMainEntry(usePrebundle: boolean): string { + return usePrebundle + ? 'import("./prebundled/packaged-main.mjs").catch((error) => {\n console.error("packaged entry failed", error);\n process.exit(1);\n});\n' + : 'import("@open-design/packaged").catch((error) => {\n console.error("packaged entry failed", error);\n process.exit(1);\n});\n'; +} diff --git a/tools/pack/src/mac.ts b/tools/pack/src/mac.ts deleted file mode 100644 index e2ac16426..000000000 --- a/tools/pack/src/mac.ts +++ /dev/null @@ -1,1317 +0,0 @@ -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { access, chmod, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; -import { promisify } from "node:util"; - -import { - APP_KEYS, - OPEN_DESIGN_SIDECAR_CONTRACT, - SIDECAR_MESSAGES, - SIDECAR_MODES, - SIDECAR_SOURCES, - type DesktopEvalResult, - type DesktopScreenshotResult, - type DesktopStatusSnapshot, - type SidecarStamp, -} from "@open-design/sidecar-proto"; -import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; -import { - collectProcessTreePids, - createPackageManagerInvocation, - createProcessStampArgs, - listProcessSnapshots, - matchesStampedProcess, - readLogTail, - spawnBackgroundProcess, - stopProcesses, -} from "@open-design/platform"; - -import type { ToolPackBuildOutput, ToolPackConfig } from "./config.js"; -import { copyBundledResourceTrees, macResources } from "./resources.js"; - -const execFileAsync = promisify(execFile); -const PRODUCT_NAME = "Open Design"; - -const INTERNAL_PACKAGES = [ - { directory: "packages/contracts", name: "@open-design/contracts" }, - { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, - { directory: "packages/sidecar", name: "@open-design/sidecar" }, - { directory: "packages/platform", name: "@open-design/platform" }, - { directory: "apps/daemon", name: "@open-design/daemon" }, - { directory: "apps/web", name: "@open-design/web" }, - { directory: "apps/desktop", name: "@open-design/desktop" }, - { directory: "apps/packaged", name: "@open-design/packaged" }, -] as const; - -type PackedTarballInfo = { - fileName: string; - packageName: (typeof INTERNAL_PACKAGES)[number]["name"]; -}; - -type MacPaths = { - appBuilderConfigPath: string; - appBuilderOutputRoot: string; - appPath: string; - assembledAppRoot: string; - assembledMainEntryPath: string; - assembledPackageJsonPath: string; - dmgPath: string; - installApplicationsRoot: string; - installedAppPath: string; - latestMacYmlPath: string; - mountPoint: string; - packagedConfigPath: string; - resourceRoot: string; - systemApplicationsAppPath: string; - tarballsRoot: string; - userApplicationsAppPath: string; - webStandaloneHookAuditPath: string; - webStandaloneHookConfigPath: string; - zipPath: string; -}; - -type SeededAppConfigPaths = { - sourcePath: string; - targetPath: string; -}; - -export type MacPackResult = { - appPath: string; - dmgPath: string | null; - latestMacYmlPath: string | null; - outputRoot: string; - resourceRoot: string; - runtimeNamespaceRoot: string; - sizeReport: MacSizeReport; - to: ToolPackBuildOutput; - zipPath: string | null; -}; - -type MacStartSource = "built" | "installed" | "system-applications" | "user-applications"; - -export type MacStartResult = { - appPath: string; - executablePath: string; - logPath: string; - namespace: string; - pid: number; - source: MacStartSource; - status: DesktopStatusSnapshot | null; -}; - -export type MacInspectResult = { - eval?: DesktopEvalResult; - screenshot?: DesktopScreenshotResult; - status: DesktopStatusSnapshot | null; -}; - -type DesktopRootIdentityMarker = { - appPath: string; - executablePath: string; - logPath: string; - namespaceRoot: string; - pid: number; - ppid: number; - stamp: SidecarStamp; - startedAt: string; - updatedAt: string; - version: 1; -}; - -type DesktopRootIdentityFallback = { - marker?: Partial; - markerPath: string; - processCommand?: string; - reason: string; -}; - -export type MacStopResult = { - fallback?: DesktopRootIdentityFallback; - gracefulRequested: boolean; - namespace: string; - remainingPids: number[]; - status: "not-running" | "partial" | "stopped" | "unmanaged"; - stoppedPids: number[]; -}; - -export type MacInstallResult = { - detached: boolean; - dmgPath: string; - installedAppPath: string; - mountPoint: string; - namespace: string; -}; - -export type MacUninstallResult = { - installedAppPath: string; - namespace: string; - removed: boolean; - stop: MacStopResult; -}; - -export type MacCleanupResult = { - detachedMount: boolean; - namespace: string; - outputRoot: string; - removedOutputRoot: boolean; - removedRuntimeNamespaceRoot: boolean; - runtimeNamespaceRoot: string; - stop: MacStopResult; -}; - -export type ElectronBuilderTarget = "dir" | "dmg" | "zip"; - -const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO"; -const WEB_STANDALONE_HOOK_CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG"; -const WEB_STANDALONE_RESOURCE_NAME = "open-design-web-standalone"; -const ELECTRON_BUILDER_ASAR = false; -const ELECTRON_BUILDER_FILE_PATTERNS = [ - "**/*", - "!**/node_modules/.bin", - "!**/node_modules/electron{,/**/*}", - "!**/*.map", - "!**/*.tsbuildinfo", - "!**/.next/cache", - "!**/.next/cache/**", - "!**/node_modules/better-sqlite3/build/Release/obj", - "!**/node_modules/better-sqlite3/build/Release/obj/**", - "!**/node_modules/better-sqlite3/deps", - "!**/node_modules/better-sqlite3/deps/**", -] as const; - -export type MacSizeReport = { - appBytes: number; - builder: { - asar: boolean; - compression: ToolPackConfig["macCompression"]; - filePatterns: readonly string[]; - targets: ElectronBuilderTarget[]; - webOutputMode: ToolPackConfig["webOutputMode"]; - }; - dmgBytes: number | null; - generatedAt: string; - outputRootBytes: number; - resourceRootBytes: number; - runtimeNamespaceRoot: string; - topLevel: { - appResourcesBytes: number; - electronFrameworksBytes: number; - resourcesBytes: number; - }; - tracked: { - appNodeModulesBytes: number; - betterSqlite3Bytes: number; - betterSqlite3SourceResidueBytes: number; - bundledNodeBytes: number; - electronLocalesBytes: number; - markdownBytes: number; - nextBytes: number; - nextSwcBytes: number; - sharpLibvipsBytes: number; - sourcemapBytes: number; - tsbuildInfoBytes: number; - webCopiedStandaloneBytes: number; - webNextCacheBytes: number; - webPackageBytes: number; - webPackageStandaloneBytes: number; - }; - zipBytes: number | null; -}; - -function sanitizeNamespace(value: string): string { - return value.replace(/[^A-Za-z0-9._-]+/g, "-"); -} - -function macAppBundleName(namespace: string): string { - return `${PRODUCT_NAME}.${sanitizeNamespace(namespace)}.app`; -} - -function macAppExecutablePath(appPath: string): string { - return join(appPath, "Contents", "MacOS", PRODUCT_NAME); -} - -function resolveMacAppOutputDirectoryName(): string { - return process.arch === "arm64" ? "mac-arm64" : "mac"; -} - -function resolveMacPaths(config: ToolPackConfig): MacPaths { - const namespaceRoot = config.roots.output.namespaceRoot; - const appBuilderOutputRoot = config.roots.output.appBuilderRoot; - const namespaceToken = sanitizeNamespace(config.namespace); - const appPath = join( - appBuilderOutputRoot, - resolveMacAppOutputDirectoryName(), - `${PRODUCT_NAME}.app`, - ); - const installApplicationsRoot = join(namespaceRoot, "install", "Applications"); - const installedAppPath = join(installApplicationsRoot, macAppBundleName(config.namespace)); - - return { - appBuilderConfigPath: join(namespaceRoot, "builder-config.json"), - appBuilderOutputRoot, - appPath, - assembledAppRoot: join(namespaceRoot, "assembled", "app"), - assembledMainEntryPath: join(namespaceRoot, "assembled", "app", "main.cjs"), - assembledPackageJsonPath: join(namespaceRoot, "assembled", "app", "package.json"), - dmgPath: join(namespaceRoot, "dmg", `${PRODUCT_NAME}-${namespaceToken}.dmg`), - installApplicationsRoot, - installedAppPath, - latestMacYmlPath: join(namespaceRoot, "zip", "latest-mac.yml"), - mountPoint: join(namespaceRoot, "mount"), - packagedConfigPath: join(namespaceRoot, "open-design-config.json"), - resourceRoot: join(namespaceRoot, "resources", "open-design"), - systemApplicationsAppPath: join("/Applications", macAppBundleName(config.namespace)), - tarballsRoot: join(namespaceRoot, "tarballs"), - userApplicationsAppPath: join(homedir(), "Applications", macAppBundleName(config.namespace)), - webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"), - webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"), - zipPath: join(namespaceRoot, "zip", `${PRODUCT_NAME}-${namespaceToken}.zip`), - }; -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function resolveSeededAppConfigPaths(config: ToolPackConfig): SeededAppConfigPaths { - const configuredDataDir = process.env.OD_DATA_DIR?.trim(); - const sourceDataDir = configuredDataDir - ? resolveProjectRelativePath(configuredDataDir, config.workspaceRoot) - : join(config.workspaceRoot, ".od"); - return { - sourcePath: join(sourceDataDir, "app-config.json"), - targetPath: join(config.roots.runtime.namespaceRoot, "data", "app-config.json"), - }; -} - -async function seedPackagedAppConfig(config: ToolPackConfig): Promise { - if (config.portable) return; - - const { sourcePath, targetPath } = resolveSeededAppConfigPaths(config); - if (!(await pathExists(sourcePath))) return; - - const raw = await readFile(sourcePath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { - throw new Error(`packaged app-config seed must be a JSON object: ${sourcePath}`); - } - - await mkdir(dirname(targetPath), { recursive: true }); - await writeFile(targetPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); -} - -function toPosixPath(value: string): string { - return value.replaceAll("\\", "/"); -} - -function expandHomePrefix(raw: string): string { - const home = homedir(); - if (raw === "~" || raw === "$HOME" || raw === "${HOME}") return home; - const match = /^(~|\$\{HOME\}|\$HOME)[/\\](.*)$/.exec(raw); - return match ? join(home, match[2] ?? "") : raw; -} - -function resolveProjectRelativePath(raw: string, projectRoot: string): string { - const expanded = expandHomePrefix(raw); - return isAbsolute(expanded) ? expanded : resolve(projectRoot, expanded); -} - -async function sizePathBytes( - path: string, - options: { includeFile?: (path: string) => boolean } = {}, -): Promise { - let metadata: Awaited>; - try { - metadata = await lstat(path); - } catch { - return 0; - } - - if (!metadata.isDirectory()) { - return options.includeFile == null || options.includeFile(toPosixPath(path)) ? metadata.size : 0; - } - - const entries = await readdir(path, { withFileTypes: true }).catch(() => []); - let total = 0; - for (const entry of entries) { - total += await sizePathBytes(join(path, entry.name), options); - } - return total; -} - -async function sizeExistingFileBytes(path: string): Promise { - try { - const metadata = await stat(path); - return metadata.size; - } catch { - return null; - } -} - -async function sumChildDirectorySizes(path: string, includeChild: (name: string) => boolean): Promise { - const entries = await readdir(path, { withFileTypes: true }).catch(() => []); - let total = 0; - for (const entry of entries) { - if (!entry.isDirectory() || !includeChild(entry.name)) continue; - total += await sizePathBytes(join(path, entry.name)); - } - return total; -} - -async function runPnpm( - config: ToolPackConfig, - args: string[], - extraEnv: NodeJS.ProcessEnv = {}, -): Promise { - const invocation = createPackageManagerInvocation(args, process.env); - await execFileAsync(invocation.command, invocation.args, { - cwd: config.workspaceRoot, - env: { ...process.env, ...extraEnv }, - }); -} - -async function runNpmInstall(appRoot: string): Promise { - await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], { - cwd: appRoot, - env: process.env, - }); -} - -async function readPackagedVersion(config: ToolPackConfig): Promise { - const packageJsonPath = join(config.workspaceRoot, "apps", "packaged", "package.json"); - const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown }; - if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { - throw new Error(`missing apps/packaged package version in ${packageJsonPath}`); - } - return packageJson.version; -} - -async function assertWebStandaloneOutput(config: ToolPackConfig): Promise { - const webRoot = join(config.workspaceRoot, "apps", "web"); - const standaloneSourceRoot = join(webRoot, ".next", "standalone"); - const candidates = [ - join(standaloneSourceRoot, "apps", "web", "server.js"), - join(standaloneSourceRoot, "server.js"), - ]; - - for (const candidate of candidates) { - if (await pathExists(candidate)) return; - } - - throw new Error("Next.js standalone server output was not produced under apps/web/.next/standalone"); -} - -async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: MacPaths): Promise { - const webRoot = join(config.workspaceRoot, "apps", "web"); - await assertWebStandaloneOutput(config); - - await mkdir(dirname(paths.webStandaloneHookConfigPath), { recursive: true }); - await writeFile( - paths.webStandaloneHookConfigPath, - `${JSON.stringify( - { - auditReportPath: paths.webStandaloneHookAuditPath, - pruneCopiedSharp: true, - pruneRootNext: true, - pruneRootSharp: true, - resourceName: WEB_STANDALONE_RESOURCE_NAME, - standaloneSourceRoot: join(webRoot, ".next", "standalone"), - version: 1, - webPublicSourceRoot: join(webRoot, "public"), - webStaticSourceRoot: join(webRoot, ".next", "static"), - workspaceRoot: config.workspaceRoot, - }, - null, - 2, - )}\n`, - "utf8", - ); - return paths.webStandaloneHookConfigPath; -} - -async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { - const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); - const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); - - await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); - await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); - await runPnpm(config, ["--filter", "@open-design/platform", "build"]); - await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); - try { - await runPnpm(config, ["--filter", "@open-design/web", "build"], { - OD_WEB_OUTPUT_MODE: config.webOutputMode, - }); - await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); - } finally { - if (previousWebNextEnv == null) { - await rm(webNextEnvPath, { force: true }); - } else { - await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); - } - } - await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); - await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); -} - -async function copyResourceTree(config: ToolPackConfig, paths: MacPaths): Promise { - await rm(paths.resourceRoot, { force: true, recursive: true }); - await mkdir(paths.resourceRoot, { recursive: true }); - - await copyBundledResourceTrees({ - workspaceRoot: config.workspaceRoot, - resourceRoot: paths.resourceRoot, - }); - await mkdir(join(paths.resourceRoot, "bin"), { recursive: true }); - await cp(process.execPath, join(paths.resourceRoot, "bin", "node")); - await chmod(join(paths.resourceRoot, "bin", "node"), 0o755); -} - -async function collectWorkspaceTarballs( - config: ToolPackConfig, - paths: MacPaths, -): Promise { - await rm(paths.tarballsRoot, { force: true, recursive: true }); - await mkdir(paths.tarballsRoot, { recursive: true }); - const packedTarballs: PackedTarballInfo[] = []; - - for (const packageInfo of INTERNAL_PACKAGES) { - const beforeEntries = new Set(await readdir(paths.tarballsRoot)); - await runPnpm(config, [ - "-C", - packageInfo.directory, - "pack", - "--pack-destination", - paths.tarballsRoot, - ]); - const afterEntries = await readdir(paths.tarballsRoot); - const newEntries = afterEntries.filter((entry) => !beforeEntries.has(entry)); - if (newEntries.length !== 1 || newEntries[0] == null) { - throw new Error(`expected one tarball for ${packageInfo.name}, got ${newEntries.length}`); - } - packedTarballs.push({ fileName: newEntries[0], packageName: packageInfo.name }); - } - - return packedTarballs; -} - -async function writeAssembledApp( - config: ToolPackConfig, - paths: MacPaths, - packedTarballs: PackedTarballInfo[], -): Promise { - const packagedVersion = await readPackagedVersion(config); - await rm(join(config.roots.output.namespaceRoot, "assembled"), { force: true, recursive: true }); - await mkdir(paths.assembledAppRoot, { recursive: true }); - const tarballByPackage = Object.fromEntries( - packedTarballs.map((entry) => [entry.packageName, entry.fileName] as const), - ); - const dependencies = Object.fromEntries( - INTERNAL_PACKAGES.map((packageInfo) => { - const tarball = tarballByPackage[packageInfo.name]; - if (tarball == null) throw new Error(`missing tarball for ${packageInfo.name}`); - return [packageInfo.name, `file:${relative(paths.assembledAppRoot, join(paths.tarballsRoot, tarball))}`]; - }), - ); - - await writeFile( - paths.assembledPackageJsonPath, - `${JSON.stringify( - { - dependencies, - description: "Open Design packaged runtime", - main: "./main.cjs", - name: "open-design-packaged-app", - private: true, - productName: PRODUCT_NAME, - version: packagedVersion, - }, - null, - 2, - )}\n`, - "utf8", - ); - await writeFile( - paths.assembledMainEntryPath, - 'import("@open-design/packaged").catch((error) => {\n console.error("packaged entry failed", error);\n process.exit(1);\n});\n', - "utf8", - ); - await writeFile( - paths.packagedConfigPath, - `${JSON.stringify( - { - appVersion: packagedVersion, - namespace: config.namespace, - nodeCommandRelative: "open-design/bin/node", - webOutputMode: config.webOutputMode, - ...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }), - }, - null, - 2, - )}\n`, - "utf8", - ); - await runNpmInstall(paths.assembledAppRoot); -} - -type MacBuildOutput = Extract; - -function resolveElectronBuilderTargets(to: MacBuildOutput): ElectronBuilderTarget[] { - switch (to) { - case "app": - return ["dir"]; - case "dmg": - return ["dir", "dmg"]; - case "zip": - return ["dir", "zip"]; - case "all": - return ["dir", "dmg", "zip"]; - } -} - -async function runElectronBuilder( - config: ToolPackConfig, - paths: MacPaths, - targets: ElectronBuilderTarget[], -): Promise { - const namespaceToken = sanitizeNamespace(config.namespace); - const packagedVersion = await readPackagedVersion(config); - const webStandaloneHookConfigPath = config.webOutputMode === "standalone" - ? await writeWebStandaloneHookConfig(config, paths) - : null; - const builderConfig = { - appId: "io.open-design.desktop", - artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`, - afterPack: webStandaloneHookConfigPath == null ? undefined : macResources.webStandaloneAfterPackHook, - afterSign: config.signed ? macResources.notarizeHook : undefined, - asar: ELECTRON_BUILDER_ASAR, - buildDependenciesFromSource: false, - compression: config.macCompression, - directories: { - output: paths.appBuilderOutputRoot, - }, - dmg: { - icon: macResources.icon, - iconSize: 96, - title: `${PRODUCT_NAME}-${namespaceToken}`, - }, - electronDist: config.electronDistPath, - electronVersion: config.electronVersion, - executableName: PRODUCT_NAME, - extraMetadata: { - main: "./main.cjs", - name: "open-design-packaged-app", - productName: PRODUCT_NAME, - version: packagedVersion, - }, - extraResources: [ - { from: paths.resourceRoot, to: "open-design" }, - { from: paths.packagedConfigPath, to: "open-design-config.json" }, - ], - files: [...ELECTRON_BUILDER_FILE_PATTERNS], - mac: { - category: "public.app-category.developer-tools", - entitlements: config.signed ? macResources.entitlements : undefined, - entitlementsInherit: config.signed ? macResources.entitlementsInherit : undefined, - gatekeeperAssess: false, - hardenedRuntime: config.signed, - icon: macResources.icon, - identity: config.signed ? undefined : null, - notarize: false, - target: targets, - }, - nodeGypRebuild: false, - npmRebuild: false, - productName: PRODUCT_NAME, - icon: macResources.icon, - publish: [ - { - provider: "generic", - url: "https://updates.invalid/open-design", - }, - ], - }; - - await rm(paths.appBuilderOutputRoot, { force: true, recursive: true }); - await mkdir(dirname(paths.appBuilderConfigPath), { recursive: true }); - await writeFile(paths.appBuilderConfigPath, `${JSON.stringify(builderConfig, null, 2)}\n`, "utf8"); - await execFileAsync(process.execPath, [ - config.electronBuilderCliPath, - "--mac", - "--projectDir", - paths.assembledAppRoot, - "--config", - paths.appBuilderConfigPath, - "--publish", - "never", - ], { - cwd: config.workspaceRoot, - env: { - ...process.env, - ...(config.signed ? {} : { CSC_IDENTITY_AUTO_DISCOVERY: "false" }), - ...(webStandaloneHookConfigPath == null ? {} : { [WEB_STANDALONE_HOOK_CONFIG_ENV]: webStandaloneHookConfigPath }), - }, - }); -} - -async function clearQuarantine(path: string): Promise { - try { - await execFileAsync("xattr", ["-dr", "com.apple.quarantine", path]); - } catch { - // Ignore when the attribute is absent or unsupported for local unsigned artifacts. - } -} - -async function moveBuilderArtifact(options: { - destinationPath: string; - label: string; - sourcePath: string; -}): Promise { - if (!(await pathExists(options.sourcePath))) { - throw new Error(`no ${options.label} produced at ${options.sourcePath}`); - } - await mkdir(dirname(options.destinationPath), { recursive: true }); - await rm(options.destinationPath, { force: true, recursive: true }); - await rename(options.sourcePath, options.destinationPath); - await clearQuarantine(options.destinationPath); - return options.destinationPath; -} - -async function cleanBuilderScratchMetadata(paths: MacPaths): Promise { - const entries = await readdir(paths.appBuilderOutputRoot).catch(() => []); - - await Promise.all( - entries - .filter((entry) => entry === "latest-mac.yml" || entry.endsWith(".blockmap")) - .map(async (entry) => { - await rm(join(paths.appBuilderOutputRoot, entry), { force: true, recursive: true }); - }), - ); -} - -async function writeLocalLatestMacYml(config: ToolPackConfig, paths: MacPaths): Promise { - const packagedVersion = await readPackagedVersion(config); - const zipName = basename(paths.zipPath); - const zipPayload = await readFile(paths.zipPath); - const zipMetadata = await stat(paths.zipPath); - const sha512 = createHash("sha512").update(zipPayload).digest("base64"); - - await mkdir(dirname(paths.latestMacYmlPath), { recursive: true }); - await writeFile( - paths.latestMacYmlPath, - [ - `version: ${JSON.stringify(packagedVersion)}`, - "files:", - ` - url: ${JSON.stringify(zipName)}`, - ` sha512: ${JSON.stringify(sha512)}`, - ` size: ${zipMetadata.size}`, - `path: ${JSON.stringify(zipName)}`, - `sha512: ${JSON.stringify(sha512)}`, - `releaseDate: ${JSON.stringify(new Date().toISOString())}`, - "", - ].join("\n"), - "utf8", - ); -} - -async function finalizeMacArtifacts( - config: ToolPackConfig, - paths: MacPaths, -): Promise> { - const namespaceToken = sanitizeNamespace(config.namespace); - let dmgPath: string | null = null; - let latestMacYmlPath: string | null = null; - let zipPath: string | null = null; - - if (config.to === "dmg" || config.to === "all") { - dmgPath = await moveBuilderArtifact({ - destinationPath: paths.dmgPath, - label: "dmg artifact", - sourcePath: join(paths.appBuilderOutputRoot, `${PRODUCT_NAME}-${namespaceToken}.dmg`), - }); - } - - if (config.to === "zip" || config.to === "all") { - zipPath = await moveBuilderArtifact({ - destinationPath: paths.zipPath, - label: "zip artifact", - sourcePath: join(paths.appBuilderOutputRoot, `${PRODUCT_NAME}-${namespaceToken}.zip`), - }); - await writeLocalLatestMacYml(config, paths); - latestMacYmlPath = paths.latestMacYmlPath; - } - - await cleanBuilderScratchMetadata(paths); - - return { dmgPath, latestMacYmlPath, zipPath }; -} - -function resolveDarwinArchToken(): "arm64" | "x64" { - return process.arch === "arm64" ? "arm64" : "x64"; -} - -function isBetterSqlite3SourceResidue(path: string): boolean { - return ( - path.includes("/node_modules/better-sqlite3/deps/") || - path.includes("/node_modules/better-sqlite3/build/Release/obj/") - ); -} - -async function collectMacSizeReport( - config: ToolPackConfig, - paths: MacPaths, - artifacts: Pick, - targets: ElectronBuilderTarget[], -): Promise { - const appResourcesRoot = join(paths.appPath, "Contents", "Resources"); - const appNodeModulesRoot = join(appResourcesRoot, "app", "node_modules"); - const electronFrameworksRoot = join(paths.appPath, "Contents", "Frameworks"); - const electronFrameworkResourcesRoot = join( - electronFrameworksRoot, - "Electron Framework.framework", - "Versions", - "A", - "Resources", - ); - const darwinArch = resolveDarwinArchToken(); - - return { - appBytes: await sizePathBytes(paths.appPath), - builder: { - asar: ELECTRON_BUILDER_ASAR, - compression: config.macCompression, - filePatterns: ELECTRON_BUILDER_FILE_PATTERNS, - targets, - webOutputMode: config.webOutputMode, - }, - dmgBytes: artifacts.dmgPath == null ? null : await sizeExistingFileBytes(artifacts.dmgPath), - generatedAt: new Date().toISOString(), - outputRootBytes: await sizePathBytes(config.roots.output.namespaceRoot), - resourceRootBytes: await sizePathBytes(paths.resourceRoot), - runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, - topLevel: { - appResourcesBytes: await sizePathBytes(join(appResourcesRoot, "app")), - electronFrameworksBytes: await sizePathBytes(electronFrameworksRoot), - resourcesBytes: await sizePathBytes(appResourcesRoot), - }, - tracked: { - appNodeModulesBytes: await sizePathBytes(appNodeModulesRoot), - betterSqlite3Bytes: await sizePathBytes(join(appNodeModulesRoot, "better-sqlite3")), - betterSqlite3SourceResidueBytes: await sizePathBytes(paths.appPath, { - includeFile: isBetterSqlite3SourceResidue, - }), - bundledNodeBytes: await sizePathBytes(join(paths.resourceRoot, "bin", "node")), - electronLocalesBytes: await sumChildDirectorySizes(electronFrameworkResourcesRoot, (name) => name.endsWith(".lproj")), - markdownBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".md") }), - nextBytes: await sizePathBytes(join(appNodeModulesRoot, "next")), - nextSwcBytes: await sumChildDirectorySizes(join(appNodeModulesRoot, "@next"), (name) => name.startsWith("swc-darwin-")), - sharpLibvipsBytes: await sizePathBytes(join(appNodeModulesRoot, "@img", `sharp-libvips-darwin-${darwinArch}`)), - sourcemapBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".map") }), - tsbuildInfoBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".tsbuildinfo") }), - webCopiedStandaloneBytes: await sizePathBytes(join(appResourcesRoot, WEB_STANDALONE_RESOURCE_NAME)), - webNextCacheBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web", ".next", "cache")), - webPackageBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web")), - webPackageStandaloneBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web", ".next", "standalone")), - }, - zipBytes: artifacts.zipPath == null ? null : await sizeExistingFileBytes(artifacts.zipPath), - }; -} - -export async function packMac(config: ToolPackConfig): Promise { - const paths = resolveMacPaths(config); - const targets = resolveElectronBuilderTargets(config.to as MacBuildOutput); - await buildWorkspaceArtifacts(config); - await seedPackagedAppConfig(config); - await copyResourceTree(config, paths); - const tarballs = await collectWorkspaceTarballs(config, paths); - await writeAssembledApp(config, paths, tarballs); - await runElectronBuilder(config, paths, targets); - await clearQuarantine(paths.appPath); - const artifacts = await finalizeMacArtifacts(config, paths); - const sizeReport = await collectMacSizeReport(config, paths, artifacts, targets); - - return { - appPath: paths.appPath, - dmgPath: artifacts.dmgPath, - latestMacYmlPath: artifacts.latestMacYmlPath, - outputRoot: config.roots.output.namespaceRoot, - resourceRoot: paths.resourceRoot, - runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, - sizeReport, - to: config.to, - zipPath: artifacts.zipPath, - }; -} - -export { resolveSeededAppConfigPaths, seedPackagedAppConfig }; - -function desktopStamp(config: ToolPackConfig): SidecarStamp { - return { - app: APP_KEYS.DESKTOP, - ipc: resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }), - mode: SIDECAR_MODES.RUNTIME, - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, - }; -} - -function desktopLogPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); -} - -function desktopIdentityPath(config: ToolPackConfig): string { - return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value != null && !Array.isArray(value); -} - -function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdentityMarker { - if (!isRecord(value)) return false; - return ( - value.version === 1 && - typeof value.pid === "number" && - typeof value.ppid === "number" && - typeof value.appPath === "string" && - typeof value.executablePath === "string" && - typeof value.logPath === "string" && - typeof value.namespaceRoot === "string" && - typeof value.startedAt === "string" && - typeof value.updatedAt === "string" && - isRecord(value.stamp) - ); -} - -function summarizeDesktopMarker( - marker: DesktopRootIdentityMarker | null, -): Partial | undefined { - if (marker == null) return undefined; - return { - appPath: marker.appPath, - executablePath: marker.executablePath, - logPath: marker.logPath, - namespaceRoot: marker.namespaceRoot, - pid: marker.pid, - ppid: marker.ppid, - stamp: marker.stamp, - startedAt: marker.startedAt, - updatedAt: marker.updatedAt, - version: marker.version, - }; -} - -async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - marker: DesktopRootIdentityMarker | null; -}> { - const markerPath = desktopIdentityPath(config); - let payload: unknown; - - try { - payload = JSON.parse(await readFile(markerPath, "utf8")); - } catch (error) { - const code = typeof error === "object" && error != null && "code" in error - ? String((error as { code?: unknown }).code) - : null; - return { - fallback: { - markerPath, - reason: code === "ENOENT" ? "marker-not-found" : "marker-read-failed", - }, - marker: null, - }; - } - - if (!isDesktopRootIdentityMarker(payload)) { - return { - fallback: { - markerPath, - reason: "marker-invalid-shape", - }, - marker: null, - }; - } - - return { - fallback: { - marker: summarizeDesktopMarker(payload), - markerPath, - reason: "marker-present", - }, - marker: payload, - }; -} - -function commandMatchesDesktopMarker( - command: string, - marker: DesktopRootIdentityMarker, -): boolean { - return command.includes(marker.executablePath) || command.includes(macAppExecutablePath(marker.appPath)); -} - -async function resolveDesktopRootIdentityFallback(config: ToolPackConfig): Promise<{ - fallback: DesktopRootIdentityFallback; - rootPid: number | null; -}> { - const { fallback, marker } = await readDesktopRootIdentityMarker(config); - if (marker == null) return { fallback, rootPid: null }; - - let stamp: SidecarStamp; - try { - stamp = OPEN_DESIGN_SIDECAR_CONTRACT.normalizeStamp(marker.stamp); - } catch { - return { - fallback: { ...fallback, reason: "marker-invalid-stamp" }, - rootPid: null, - }; - } - - const expectedIpc = resolveAppIpcPath({ - app: APP_KEYS.DESKTOP, - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - namespace: config.namespace, - }); - if ( - stamp.app !== APP_KEYS.DESKTOP || - stamp.mode !== SIDECAR_MODES.RUNTIME || - stamp.namespace !== config.namespace || - stamp.ipc !== expectedIpc || - (stamp.source !== SIDECAR_SOURCES.PACKAGED && stamp.source !== SIDECAR_SOURCES.TOOLS_PACK) - ) { - return { - fallback: { ...fallback, reason: "marker-stamp-mismatch" }, - rootPid: null, - }; - } - - if (marker.namespaceRoot !== config.roots.runtime.namespaceRoot) { - return { - fallback: { ...fallback, reason: "marker-namespace-root-mismatch" }, - rootPid: null, - }; - } - - const processes = await listProcessSnapshots(); - const processInfo = processes.find((entry) => entry.pid === marker.pid) ?? null; - if (processInfo == null) { - return { - fallback: { ...fallback, reason: "marker-pid-not-running" }, - rootPid: null, - }; - } - - if (!commandMatchesDesktopMarker(processInfo.command, marker)) { - return { - fallback: { - ...fallback, - processCommand: processInfo.command, - reason: "marker-command-mismatch", - }, - rootPid: null, - }; - } - - return { - fallback: { - ...fallback, - processCommand: processInfo.command, - reason: "marker-matched", - }, - rootPid: marker.pid, - }; -} - -function isUnmanagedDesktopFallback(fallback: DesktopRootIdentityFallback | undefined): boolean { - return fallback != null && ![ - "marker-matched", - "marker-not-found", - "marker-pid-not-running", - ].includes(fallback.reason); -} - -async function waitForDesktopStatus(config: ToolPackConfig, timeoutMs = 45_000): Promise { - const stamp = desktopStamp(config); - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - try { - return await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 1000 }); - } catch { - await new Promise((resolveWait) => setTimeout(resolveWait, 200)); - } - } - return null; -} - -async function resolvePackedMacStartTarget(config: ToolPackConfig): Promise<{ - appPath: string; - executablePath: string; - source: MacStartSource; -}> { - const paths = resolveMacPaths(config); - const candidates: Array<{ appPath: string; source: MacStartSource }> = [ - { appPath: paths.installedAppPath, source: "installed" }, - { appPath: paths.userApplicationsAppPath, source: "user-applications" }, - { appPath: paths.systemApplicationsAppPath, source: "system-applications" }, - { appPath: paths.appPath, source: "built" }, - ]; - - for (const candidate of candidates) { - const executablePath = macAppExecutablePath(candidate.appPath); - if (await pathExists(executablePath)) { - return { ...candidate, executablePath }; - } - } - - throw new Error( - `no mac .app executable found for namespace=${config.namespace}; run tools-pack mac build --to all and tools-pack mac install first`, - ); -} - -async function detachMount(mountPoint: string): Promise { - try { - await execFileAsync("hdiutil", ["detach", mountPoint, "-quiet"]); - return true; - } catch { - try { - await execFileAsync("hdiutil", ["detach", mountPoint, "-force", "-quiet"]); - return true; - } catch { - return false; - } - } -} - -export async function installPackedMacDmg(config: ToolPackConfig): Promise { - const paths = resolveMacPaths(config); - if (!(await pathExists(paths.dmgPath))) { - throw new Error(`no mac dmg found at ${paths.dmgPath}; run tools-pack mac build --to all first`); - } - - await rm(paths.mountPoint, { force: true, recursive: true }); - await mkdir(paths.mountPoint, { recursive: true }); - await rm(paths.installedAppPath, { force: true, recursive: true }); - await mkdir(paths.installApplicationsRoot, { recursive: true }); - - let detached = false; - try { - await execFileAsync("hdiutil", [ - "attach", - paths.dmgPath, - "-mountpoint", - paths.mountPoint, - "-nobrowse", - "-quiet", - ]); - await execFileAsync("ditto", [join(paths.mountPoint, `${PRODUCT_NAME}.app`), paths.installedAppPath]); - await clearQuarantine(paths.installedAppPath); - } finally { - detached = await detachMount(paths.mountPoint); - } - - return { - detached, - dmgPath: paths.dmgPath, - installedAppPath: paths.installedAppPath, - mountPoint: paths.mountPoint, - namespace: config.namespace, - }; -} - -export async function startPackedMacApp(config: ToolPackConfig): Promise { - const target = await resolvePackedMacStartTarget(config); - const stamp = desktopStamp(config); - const logPath = desktopLogPath(config); - await mkdir(dirname(logPath), { recursive: true }); - await writeFile(logPath, "", "utf8"); - - const spawned = await spawnBackgroundProcess({ - args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), - command: target.executablePath, - cwd: target.appPath, - env: createSidecarLaunchEnv({ - base: join(config.roots.runtime.namespaceRoot, "runtime"), - contract: OPEN_DESIGN_SIDECAR_CONTRACT, - extraEnv: { - ...process.env, - [DESKTOP_LOG_ECHO_ENV]: "0", - }, - stamp, - }), - logFd: null, - }); - const status = await waitForDesktopStatus(config); - return { - appPath: target.appPath, - executablePath: target.executablePath, - logPath, - namespace: config.namespace, - pid: spawned.pid, - source: target.source, - status, - }; -} - -async function findManagedDesktopProcessTree(config: ToolPackConfig): Promise<{ - fallback?: DesktopRootIdentityFallback; - pids: number[]; -}> { - const processes = await listProcessSnapshots(); - const stampedRootPids = processes - .filter((processInfo) => - matchesStampedProcess(processInfo, { - mode: SIDECAR_MODES.RUNTIME, - namespace: config.namespace, - source: SIDECAR_SOURCES.TOOLS_PACK, - }, OPEN_DESIGN_SIDECAR_CONTRACT), - ) - .map((processInfo) => processInfo.pid); - const identity = await resolveDesktopRootIdentityFallback(config); - const pids = collectProcessTreePids(processes, [ - ...stampedRootPids, - identity.rootPid, - ]); - return { fallback: identity.fallback, pids }; -} - -async function waitForNoManagedDesktopProcesses( - config: ToolPackConfig, - timeoutMs = 6000, -): Promise<{ fallback?: DesktopRootIdentityFallback; pids: number[] }> { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - const current = await findManagedDesktopProcessTree(config); - if (current.pids.length === 0) return current; - await new Promise((resolveWait) => setTimeout(resolveWait, 150)); - } - return await findManagedDesktopProcessTree(config); -} - -export async function stopPackedMacApp(config: ToolPackConfig): Promise { - const stamp = desktopStamp(config); - const before = await findManagedDesktopProcessTree(config); - let gracefulRequested = false; - - try { - await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); - gracefulRequested = true; - } catch { - gracefulRequested = false; - } - - const remainingAfterGraceful = gracefulRequested ? await waitForNoManagedDesktopProcesses(config) : before; - if (remainingAfterGraceful.pids.length === 0) { - const unmanaged = !gracefulRequested && before.pids.length === 0 && isUnmanagedDesktopFallback(before.fallback); - if (!unmanaged) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - } - return { - ...(before.fallback == null ? {} : { fallback: before.fallback }), - gracefulRequested, - namespace: config.namespace, - remainingPids: [], - status: unmanaged ? "unmanaged" : before.pids.length === 0 ? "not-running" : "stopped", - stoppedPids: before.pids, - }; - } - - const stopped = await stopProcesses(remainingAfterGraceful.pids); - if (stopped.remainingPids.length === 0) { - await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); - } - return { - ...(remainingAfterGraceful.fallback == null ? {} : { fallback: remainingAfterGraceful.fallback }), - gracefulRequested, - namespace: config.namespace, - remainingPids: stopped.remainingPids, - status: stopped.remainingPids.length === 0 ? "stopped" : "partial", - stoppedPids: stopped.stoppedPids, - }; -} - -export async function readPackedMacLogs(config: ToolPackConfig) { - const entries = await Promise.all( - [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON].map(async (app) => { - const logPath = join(config.roots.runtime.namespaceRoot, "logs", app, "latest.log"); - return [app, { lines: await readLogTail(logPath, 200), logPath }] as const; - }), - ); - - return { - logs: Object.fromEntries(entries), - namespace: config.namespace, - }; -} - -export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise { - const stamp = desktopStamp(config); - const status = await requestJsonIpc( - stamp.ipc, - { type: SIDECAR_MESSAGES.STATUS }, - { timeoutMs: 2000 }, - ).catch(() => null); - - return { - ...(options.expr == null ? {} : { - eval: await requestJsonIpc( - stamp.ipc, - { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, - { timeoutMs: 5000 }, - ), - }), - ...(options.path == null ? {} : { - screenshot: await requestJsonIpc( - stamp.ipc, - { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, - { timeoutMs: 10000 }, - ), - }), - status, - }; -} - -export async function uninstallPackedMacApp(config: ToolPackConfig): Promise { - const paths = resolveMacPaths(config); - const stop = await stopPackedMacApp(config); - const removed = await pathExists(paths.installedAppPath); - await rm(paths.installedAppPath, { force: true, recursive: true }); - - return { - installedAppPath: paths.installedAppPath, - namespace: config.namespace, - removed, - stop, - }; -} - -export async function cleanupPackedMacNamespace(config: ToolPackConfig): Promise { - const paths = resolveMacPaths(config); - const stop = await stopPackedMacApp(config); - const detachedMount = await detachMount(paths.mountPoint); - const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot); - const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot); - - await rm(config.roots.output.namespaceRoot, { force: true, recursive: true }); - await rm(config.roots.runtime.namespaceRoot, { force: true, recursive: true }); - - return { - detachedMount, - namespace: config.namespace, - outputRoot: config.roots.output.namespaceRoot, - removedOutputRoot, - removedRuntimeNamespaceRoot, - runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, - stop, - }; -} diff --git a/tools/pack/src/mac/app-config.ts b/tools/pack/src/mac/app-config.ts new file mode 100644 index 000000000..9fe9c56c5 --- /dev/null +++ b/tools/pack/src/mac/app-config.ts @@ -0,0 +1,46 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; +import { pathExists } from "./fs.js"; +import type { SeededAppConfigPaths } from "./types.js"; + +export function resolveSeededAppConfigPaths(config: ToolPackConfig): SeededAppConfigPaths { + const configuredDataDir = process.env.OD_DATA_DIR?.trim(); + const sourceDataDir = configuredDataDir + ? resolveProjectRelativePath(configuredDataDir, config.workspaceRoot) + : join(config.workspaceRoot, ".od"); + return { + sourcePath: join(sourceDataDir, "app-config.json"), + targetPath: join(config.roots.runtime.namespaceRoot, "data", "app-config.json"), + }; +} + +export async function seedPackagedAppConfig(config: ToolPackConfig): Promise { + if (config.portable) return; + + const { sourcePath, targetPath } = resolveSeededAppConfigPaths(config); + if (!(await pathExists(sourcePath))) return; + + const raw = await readFile(sourcePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed == null || Array.isArray(parsed)) { + throw new Error(`packaged app-config seed must be a JSON object: ${sourcePath}`); + } + + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); +} + +function expandHomePrefix(raw: string): string { + const home = homedir(); + if (raw === "~" || raw === "$HOME" || raw === "${HOME}") return home; + const match = /^(~|\$\{HOME\}|\$HOME)[/\\](.*)$/.exec(raw); + return match ? join(home, match[2] ?? "") : raw; +} + +function resolveProjectRelativePath(raw: string, projectRoot: string): string { + const expanded = expandHomePrefix(raw); + return isAbsolute(expanded) ? expanded : resolve(projectRoot, expanded); +} diff --git a/tools/pack/src/mac/app.ts b/tools/pack/src/mac/app.ts new file mode 100644 index 000000000..ab2b6f1de --- /dev/null +++ b/tools/pack/src/mac/app.ts @@ -0,0 +1,246 @@ +import { chmod, cp, mkdir, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; +import { + MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER, + MAC_PREBUNDLE_ESBUILD_TARGET, + MAC_PREBUNDLE_POLICIES, + MAC_PREBUNDLE_RUNTIME_DEPENDENCIES, + MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH, + MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH, + MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH, + assertMacPrebundleMetafile, + renderMacPackagedMainEntry, + shouldInstallInternalPackageForMacPrebundle, + shouldUseMacStandalonePrebundle, +} from "../mac-prebundle.js"; +import { copyBundledResourceTrees } from "../resources.js"; +import { runEsbuild, runNpmInstall, runPnpm } from "./commands.js"; +import { INTERNAL_PACKAGES, PRODUCT_NAME } from "./constants.js"; +import { readPackagedVersion } from "./manifest.js"; +import type { MacPaths, PackedTarballInfo } from "./types.js"; + +function toPosixPath(value: string): string { + return value.replaceAll("\\", "/"); +} + +function toRelativeImportSpecifier(fromDirectory: string, targetPath: string): string { + const specifier = toPosixPath(relative(fromDirectory, targetPath)); + return specifier.startsWith(".") ? specifier : `./${specifier}`; +} + +async function buildPrebundledStandaloneRuntime( + config: ToolPackConfig, + paths: MacPaths, +): Promise { + await mkdir(paths.assembledPrebundledRoot, { recursive: true }); + await mkdir(dirname(paths.packagedMainPrebundleMetaPath), { recursive: true }); + await runEsbuild(config, [ + join(config.workspaceRoot, "apps", "packaged", "dist", "index.mjs"), + "--bundle", + "--platform=node", + "--format=esm", + `--target=${MAC_PREBUNDLE_ESBUILD_TARGET}`, + ...MAC_PREBUNDLE_POLICIES.packagedMain.externals.map((dependency) => `--external:${dependency}`), + `--outfile=${paths.packagedMainPrebundlePath}`, + `--metafile=${paths.packagedMainPrebundleMetaPath}`, + ]); + await assertMacPrebundleMetafile({ + metafilePath: paths.packagedMainPrebundleMetaPath, + policyName: "packagedMain", + }); + + await runEsbuild(config, [ + join(config.workspaceRoot, "apps", "web", "dist", "sidecar", "index.js"), + "--bundle", + "--platform=node", + "--format=esm", + `--target=${MAC_PREBUNDLE_ESBUILD_TARGET}`, + ...MAC_PREBUNDLE_POLICIES.webSidecar.externals.map((dependency) => `--external:${dependency}`), + `--outfile=${paths.webSidecarPrebundlePath}`, + `--metafile=${paths.webSidecarPrebundleMetaPath}`, + ]); + await assertMacPrebundleMetafile({ + metafilePath: paths.webSidecarPrebundleMetaPath, + policyName: "webSidecar", + }); + + await mkdir(dirname(paths.daemonSidecarPrebundleEntrypointPath), { recursive: true }); + await writeFile( + paths.daemonSidecarPrebundleEntrypointPath, + `import ${JSON.stringify( + toRelativeImportSpecifier( + dirname(paths.daemonSidecarPrebundleEntrypointPath), + join(config.workspaceRoot, "apps", "daemon", "dist", "sidecar", "index.js"), + ), + )};\n`, + "utf8", + ); + await writeFile( + paths.daemonCliPrebundleEntrypointPath, + [ + 'import { fileURLToPath } from "node:url";', + "const selfPath = fileURLToPath(import.meta.url);", + "process.env.OD_BIN ??= selfPath;", + "process.env.OD_DAEMON_CLI_PATH ??= selfPath;", + `await import(${JSON.stringify( + toRelativeImportSpecifier( + dirname(paths.daemonCliPrebundleEntrypointPath), + join(config.workspaceRoot, "apps", "daemon", "dist", "cli.js"), + ), + )});`, + "", + ].join("\n"), + "utf8", + ); + await runEsbuild(config, [ + paths.daemonSidecarPrebundleEntrypointPath, + paths.daemonCliPrebundleEntrypointPath, + "--bundle", + "--splitting", + "--platform=node", + "--format=esm", + `--target=${MAC_PREBUNDLE_ESBUILD_TARGET}`, + `--banner:js=${MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER}`, + ...MAC_PREBUNDLE_POLICIES.daemonSidecar.externals.map((dependency) => `--external:${dependency}`), + `--outdir=${paths.daemonPrebundleRoot}`, + "--entry-names=[name]", + "--chunk-names=chunks/[name]-[hash]", + "--out-extension:.js=.mjs", + `--metafile=${paths.daemonPrebundleMetaPath}`, + ]); + await assertMacPrebundleMetafile({ + metafilePath: paths.daemonPrebundleMetaPath, + policyName: "daemonSidecar", + }); + await assertMacPrebundleMetafile({ + metafilePath: paths.daemonPrebundleMetaPath, + policyName: "daemonCli", + }); +} + +export async function copyResourceTree(config: ToolPackConfig, paths: MacPaths): Promise { + await rm(paths.resourceRoot, { force: true, recursive: true }); + await mkdir(paths.resourceRoot, { recursive: true }); + + await copyBundledResourceTrees({ + workspaceRoot: config.workspaceRoot, + resourceRoot: paths.resourceRoot, + }); + await mkdir(join(paths.resourceRoot, "bin"), { recursive: true }); + await cp(process.execPath, join(paths.resourceRoot, "bin", "node")); + await chmod(join(paths.resourceRoot, "bin", "node"), 0o755); +} + +export async function collectWorkspaceTarballs( + config: ToolPackConfig, + paths: MacPaths, +): Promise { + await rm(paths.tarballsRoot, { force: true, recursive: true }); + await mkdir(paths.tarballsRoot, { recursive: true }); + const packedTarballs: PackedTarballInfo[] = []; + + for (const packageInfo of INTERNAL_PACKAGES) { + if ( + !shouldInstallInternalPackageForMacPrebundle({ + packageName: packageInfo.name, + webOutputMode: config.webOutputMode, + }) + ) { + continue; + } + + const beforeEntries = new Set(await readdir(paths.tarballsRoot)); + await runPnpm(config, [ + "-C", + packageInfo.directory, + "pack", + "--pack-destination", + paths.tarballsRoot, + ]); + const afterEntries = await readdir(paths.tarballsRoot); + const newEntries = afterEntries.filter((entry) => !beforeEntries.has(entry)); + if (newEntries.length !== 1 || newEntries[0] == null) { + throw new Error(`expected one tarball for ${packageInfo.name}, got ${newEntries.length}`); + } + packedTarballs.push({ fileName: newEntries[0], packageName: packageInfo.name }); + } + + return packedTarballs; +} + +export async function writeAssembledApp( + config: ToolPackConfig, + paths: MacPaths, + packedTarballs: PackedTarballInfo[], +): Promise { + const packagedVersion = await readPackagedVersion(config); + await rm(join(config.roots.output.namespaceRoot, "assembled"), { force: true, recursive: true }); + await mkdir(paths.assembledAppRoot, { recursive: true }); + const tarballByPackage = Object.fromEntries( + packedTarballs.map((entry) => [entry.packageName, entry.fileName] as const), + ); + const usePrebundledStandaloneWeb = shouldUseMacStandalonePrebundle(config.webOutputMode); + const internalDependencies = Object.fromEntries( + INTERNAL_PACKAGES.filter((packageInfo) => + shouldInstallInternalPackageForMacPrebundle({ + packageName: packageInfo.name, + webOutputMode: config.webOutputMode, + }) + ).map((packageInfo) => { + const tarball = tarballByPackage[packageInfo.name]; + if (tarball == null) throw new Error(`missing tarball for ${packageInfo.name}`); + return [packageInfo.name, `file:${relative(paths.assembledAppRoot, join(paths.tarballsRoot, tarball))}`]; + }), + ); + const dependencies = { + ...internalDependencies, + ...(usePrebundledStandaloneWeb ? MAC_PREBUNDLE_RUNTIME_DEPENDENCIES : {}), + }; + + await writeFile( + paths.assembledPackageJsonPath, + `${JSON.stringify( + { + dependencies, + description: "Open Design packaged runtime", + main: "./main.cjs", + name: "open-design-packaged-app", + private: true, + productName: PRODUCT_NAME, + version: packagedVersion, + }, + null, + 2, + )}\n`, + "utf8", + ); + if (usePrebundledStandaloneWeb) { + await buildPrebundledStandaloneRuntime(config, paths); + } + await writeFile( + paths.assembledMainEntryPath, + renderMacPackagedMainEntry(usePrebundledStandaloneWeb), + "utf8", + ); + await writeFile( + paths.packagedConfigPath, + `${JSON.stringify( + { + appVersion: packagedVersion, + ...(usePrebundledStandaloneWeb ? { daemonCliEntryRelative: MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH } : {}), + ...(usePrebundledStandaloneWeb ? { daemonSidecarEntryRelative: MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH } : {}), + namespace: config.namespace, + nodeCommandRelative: "open-design/bin/node", + ...(usePrebundledStandaloneWeb ? { webSidecarEntryRelative: MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH } : {}), + webOutputMode: config.webOutputMode, + ...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }), + }, + null, + 2, + )}\n`, + "utf8", + ); + await runNpmInstall(paths.assembledAppRoot); +} diff --git a/tools/pack/src/mac/artifacts.ts b/tools/pack/src/mac/artifacts.ts new file mode 100644 index 000000000..5c0f55c9e --- /dev/null +++ b/tools/pack/src/mac/artifacts.ts @@ -0,0 +1,94 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; +import { PRODUCT_NAME } from "./constants.js"; +import { clearQuarantine, pathExists } from "./fs.js"; +import { readPackagedVersion } from "./manifest.js"; +import { sanitizeNamespace } from "./paths.js"; +import type { MacPackResult, MacPaths } from "./types.js"; + +async function moveBuilderArtifact(options: { + destinationPath: string; + label: string; + sourcePath: string; +}): Promise { + if (!(await pathExists(options.sourcePath))) { + throw new Error(`no ${options.label} produced at ${options.sourcePath}`); + } + await mkdir(dirname(options.destinationPath), { recursive: true }); + await rm(options.destinationPath, { force: true, recursive: true }); + await rename(options.sourcePath, options.destinationPath); + await clearQuarantine(options.destinationPath); + return options.destinationPath; +} + +async function cleanBuilderScratchMetadata(paths: MacPaths): Promise { + const entries = await readdir(paths.appBuilderOutputRoot).catch(() => []); + + await Promise.all( + entries + .filter((entry) => entry === "latest-mac.yml" || entry.endsWith(".blockmap")) + .map(async (entry) => { + await rm(join(paths.appBuilderOutputRoot, entry), { force: true, recursive: true }); + }), + ); +} + +async function writeLocalLatestMacYml(config: ToolPackConfig, paths: MacPaths): Promise { + const packagedVersion = await readPackagedVersion(config); + const zipName = basename(paths.zipPath); + const zipPayload = await readFile(paths.zipPath); + const zipMetadata = await stat(paths.zipPath); + const sha512 = createHash("sha512").update(zipPayload).digest("base64"); + + await mkdir(dirname(paths.latestMacYmlPath), { recursive: true }); + await writeFile( + paths.latestMacYmlPath, + [ + `version: ${JSON.stringify(packagedVersion)}`, + "files:", + ` - url: ${JSON.stringify(zipName)}`, + ` sha512: ${JSON.stringify(sha512)}`, + ` size: ${zipMetadata.size}`, + `path: ${JSON.stringify(zipName)}`, + `sha512: ${JSON.stringify(sha512)}`, + `releaseDate: ${JSON.stringify(new Date().toISOString())}`, + "", + ].join("\n"), + "utf8", + ); +} + +export async function finalizeMacArtifacts( + config: ToolPackConfig, + paths: MacPaths, +): Promise> { + const namespaceToken = sanitizeNamespace(config.namespace); + let dmgPath: string | null = null; + let latestMacYmlPath: string | null = null; + let zipPath: string | null = null; + + if (config.to === "dmg" || config.to === "all") { + dmgPath = await moveBuilderArtifact({ + destinationPath: paths.dmgPath, + label: "dmg artifact", + sourcePath: join(paths.appBuilderOutputRoot, `${PRODUCT_NAME}-${namespaceToken}.dmg`), + }); + } + + if (config.to === "zip" || config.to === "all") { + zipPath = await moveBuilderArtifact({ + destinationPath: paths.zipPath, + label: "zip artifact", + sourcePath: join(paths.appBuilderOutputRoot, `${PRODUCT_NAME}-${namespaceToken}.zip`), + }); + await writeLocalLatestMacYml(config, paths); + latestMacYmlPath = paths.latestMacYmlPath; + } + + await cleanBuilderScratchMetadata(paths); + + return { dmgPath, latestMacYmlPath, zipPath }; +} diff --git a/tools/pack/src/mac/build.ts b/tools/pack/src/mac/build.ts new file mode 100644 index 000000000..3b9d20c1f --- /dev/null +++ b/tools/pack/src/mac/build.ts @@ -0,0 +1,62 @@ +import { ToolPackCache } from "../cache.js"; +import type { ToolPackConfig } from "../config.js"; +import { collectWorkspaceTarballs, copyResourceTree, writeAssembledApp } from "./app.js"; +import { seedPackagedAppConfig } from "./app-config.js"; +import { finalizeMacArtifacts } from "./artifacts.js"; +import { resolveElectronBuilderTargets, runElectronBuilder } from "./builder.js"; +import { clearQuarantine } from "./fs.js"; +import { resolveMacPaths } from "./paths.js"; +import { collectMacSizeReport } from "./report.js"; +import type { MacBuildOutput, MacPackResult, MacPackTiming } from "./types.js"; +import { ensureMacWorkspaceBuild } from "./workspace.js"; + +export async function packMac(config: ToolPackConfig): Promise { + const paths = resolveMacPaths(config); + const targets = resolveElectronBuilderTargets(config.to as MacBuildOutput); + const cache = new ToolPackCache(config.roots.cacheRoot); + const timings: MacPackTiming[] = []; + const runPhase = async (phase: string, task: () => Promise): Promise => { + const startedAt = Date.now(); + try { + return await task(); + } finally { + timings.push({ durationMs: Date.now() - startedAt, phase }); + } + }; + + await runPhase("workspace-build", async () => { + await ensureMacWorkspaceBuild(config, cache); + }); + await runPhase("seed-app-config", async () => { + await seedPackagedAppConfig(config); + }); + await runPhase("resource-tree", async () => { + await copyResourceTree(config, paths); + }); + const tarballs = await runPhase("workspace-tarballs", async () => collectWorkspaceTarballs(config, paths)); + await runPhase("assembled-app", async () => { + await writeAssembledApp(config, paths, tarballs); + }); + await runPhase("electron-builder", async () => { + await runElectronBuilder(config, paths, targets); + }); + await runPhase("quarantine", async () => { + await clearQuarantine(paths.appPath); + }); + const artifacts = await runPhase("artifacts", async () => finalizeMacArtifacts(config, paths)); + const sizeReport = await runPhase("size-report", async () => collectMacSizeReport(config, paths, artifacts, targets)); + + return { + appPath: paths.appPath, + cacheReport: cache.report(), + dmgPath: artifacts.dmgPath, + latestMacYmlPath: artifacts.latestMacYmlPath, + outputRoot: config.roots.output.namespaceRoot, + resourceRoot: paths.resourceRoot, + runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, + sizeReport, + timings, + to: config.to, + zipPath: artifacts.zipPath, + }; +} diff --git a/tools/pack/src/mac/builder.ts b/tools/pack/src/mac/builder.ts new file mode 100644 index 000000000..41252b6ce --- /dev/null +++ b/tools/pack/src/mac/builder.ts @@ -0,0 +1,160 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; +import { macResources } from "../resources.js"; +import { execFileAsync } from "./commands.js"; +import { + ELECTRON_BUILDER_ASAR, + ELECTRON_BUILDER_FILE_PATTERNS, + MAC_ELECTRON_LANGUAGES, + PRODUCT_NAME, + WEB_STANDALONE_HOOK_CONFIG_ENV, + WEB_STANDALONE_RESOURCE_NAME, +} from "./constants.js"; +import { pathExists } from "./fs.js"; +import { readPackagedVersion } from "./manifest.js"; +import { sanitizeNamespace } from "./paths.js"; +import type { ElectronBuilderTarget, MacBuildOutput, MacPaths } from "./types.js"; + +async function assertWebStandaloneOutput(config: ToolPackConfig): Promise { + const webRoot = join(config.workspaceRoot, "apps", "web"); + const standaloneSourceRoot = join(webRoot, ".next", "standalone"); + const candidates = [ + join(standaloneSourceRoot, "apps", "web", "server.js"), + join(standaloneSourceRoot, "server.js"), + ]; + + for (const candidate of candidates) { + if (await pathExists(candidate)) return; + } + + throw new Error("Next.js standalone server output was not produced under apps/web/.next/standalone"); +} + +async function writeWebStandaloneHookConfig(config: ToolPackConfig, paths: MacPaths): Promise { + const webRoot = join(config.workspaceRoot, "apps", "web"); + await assertWebStandaloneOutput(config); + + await mkdir(dirname(paths.webStandaloneHookConfigPath), { recursive: true }); + await writeFile( + paths.webStandaloneHookConfigPath, + `${JSON.stringify( + { + auditReportPath: paths.webStandaloneHookAuditPath, + pruneCopiedSharp: true, + pruneRootNext: true, + pruneRootSharp: true, + resourceName: WEB_STANDALONE_RESOURCE_NAME, + standaloneSourceRoot: join(webRoot, ".next", "standalone"), + version: 1, + webPublicSourceRoot: join(webRoot, "public"), + webStaticSourceRoot: join(webRoot, ".next", "static"), + workspaceRoot: config.workspaceRoot, + }, + null, + 2, + )}\n`, + "utf8", + ); + return paths.webStandaloneHookConfigPath; +} + +export function resolveElectronBuilderTargets(to: MacBuildOutput): ElectronBuilderTarget[] { + switch (to) { + case "app": + return ["dir"]; + case "dmg": + return ["dir", "dmg"]; + case "zip": + return ["dir", "zip"]; + case "all": + return ["dir", "dmg", "zip"]; + } +} + +export async function runElectronBuilder( + config: ToolPackConfig, + paths: MacPaths, + targets: ElectronBuilderTarget[], +): Promise { + const namespaceToken = sanitizeNamespace(config.namespace); + const packagedVersion = await readPackagedVersion(config); + const webStandaloneHookConfigPath = config.webOutputMode === "standalone" + ? await writeWebStandaloneHookConfig(config, paths) + : null; + const builderConfig = { + appId: "io.open-design.desktop", + artifactName: `${PRODUCT_NAME}-${namespaceToken}.\${ext}`, + afterPack: webStandaloneHookConfigPath == null ? undefined : macResources.webStandaloneAfterPackHook, + afterSign: config.signed ? macResources.notarizeHook : undefined, + asar: ELECTRON_BUILDER_ASAR, + buildDependenciesFromSource: false, + compression: config.macCompression, + directories: { + output: paths.appBuilderOutputRoot, + }, + dmg: { + icon: macResources.icon, + iconSize: 96, + title: `${PRODUCT_NAME}-${namespaceToken}`, + }, + electronDist: config.electronDistPath, + electronVersion: config.electronVersion, + executableName: PRODUCT_NAME, + extraMetadata: { + main: "./main.cjs", + name: "open-design-packaged-app", + productName: PRODUCT_NAME, + version: packagedVersion, + }, + extraResources: [ + { from: paths.resourceRoot, to: "open-design" }, + { from: paths.packagedConfigPath, to: "open-design-config.json" }, + ], + files: [...ELECTRON_BUILDER_FILE_PATTERNS], + mac: { + category: "public.app-category.developer-tools", + electronLanguages: MAC_ELECTRON_LANGUAGES, + entitlements: config.signed ? macResources.entitlements : undefined, + entitlementsInherit: config.signed ? macResources.entitlementsInherit : undefined, + gatekeeperAssess: false, + hardenedRuntime: config.signed, + icon: macResources.icon, + identity: config.signed ? undefined : null, + notarize: false, + target: targets, + }, + nodeGypRebuild: false, + npmRebuild: false, + productName: PRODUCT_NAME, + icon: macResources.icon, + publish: [ + { + provider: "generic", + url: "https://updates.invalid/open-design", + }, + ], + }; + + await rm(paths.appBuilderOutputRoot, { force: true, recursive: true }); + await mkdir(dirname(paths.appBuilderConfigPath), { recursive: true }); + await writeFile(paths.appBuilderConfigPath, `${JSON.stringify(builderConfig, null, 2)}\n`, "utf8"); + await execFileAsync(process.execPath, [ + config.electronBuilderCliPath, + "--mac", + "--projectDir", + paths.assembledAppRoot, + "--config", + paths.appBuilderConfigPath, + "--publish", + "never", + ], { + cwd: config.workspaceRoot, + env: { + ...process.env, + ...(config.signed ? {} : { CSC_IDENTITY_AUTO_DISCOVERY: "false" }), + ...(webStandaloneHookConfigPath == null ? {} : { [WEB_STANDALONE_HOOK_CONFIG_ENV]: webStandaloneHookConfigPath }), + }, + }); +} diff --git a/tools/pack/src/mac/commands.ts b/tools/pack/src/mac/commands.ts new file mode 100644 index 000000000..e6ef67f7c --- /dev/null +++ b/tools/pack/src/mac/commands.ts @@ -0,0 +1,31 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import { createPackageManagerInvocation } from "@open-design/platform"; + +import type { ToolPackConfig } from "../config.js"; + +export const execFileAsync = promisify(execFile); + +export async function runPnpm( + config: ToolPackConfig, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +): Promise { + const invocation = createPackageManagerInvocation(args, process.env); + await execFileAsync(invocation.command, invocation.args, { + cwd: config.workspaceRoot, + env: { ...process.env, ...extraEnv }, + }); +} + +export async function runNpmInstall(appRoot: string): Promise { + await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], { + cwd: appRoot, + env: process.env, + }); +} + +export async function runEsbuild(config: ToolPackConfig, args: string[]): Promise { + await runPnpm(config, ["--filter", "@open-design/packaged", "exec", "esbuild", ...args]); +} diff --git a/tools/pack/src/mac/constants.ts b/tools/pack/src/mac/constants.ts new file mode 100644 index 000000000..bebe26c2d --- /dev/null +++ b/tools/pack/src/mac/constants.ts @@ -0,0 +1,52 @@ +export const PRODUCT_NAME = "Open Design"; + +export const INTERNAL_PACKAGES = [ + { directory: "packages/contracts", name: "@open-design/contracts" }, + { directory: "packages/sidecar-proto", name: "@open-design/sidecar-proto" }, + { directory: "packages/sidecar", name: "@open-design/sidecar" }, + { directory: "packages/platform", name: "@open-design/platform" }, + { directory: "apps/daemon", name: "@open-design/daemon" }, + { directory: "apps/web", name: "@open-design/web" }, + { directory: "apps/desktop", name: "@open-design/desktop" }, + { directory: "apps/packaged", name: "@open-design/packaged" }, +] as const; + +export const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO"; +export const WEB_STANDALONE_HOOK_CONFIG_ENV = "OD_TOOLS_PACK_WEB_STANDALONE_HOOK_CONFIG"; +export const WEB_STANDALONE_RESOURCE_NAME = "open-design-web-standalone"; +export const ELECTRON_BUILDER_ASAR = false; +export const ELECTRON_BUILDER_FILE_PATTERNS = [ + "**/*", + "!**/node_modules/.bin", + "!**/node_modules/electron{,/**/*}", + "!**/*.map", + "!**/*.tsbuildinfo", + "!**/.next/cache", + "!**/.next/cache/**", + "!**/node_modules/better-sqlite3/build/Release/obj", + "!**/node_modules/better-sqlite3/build/Release/obj/**", + "!**/node_modules/better-sqlite3/deps", + "!**/node_modules/better-sqlite3/deps/**", +] as const; +// Keep Electron native UI resources aligned with the Web UI locale set. +// Electron uses underscore-separated locale ids; its base "es" resource +// covers the app's es-ES dictionary. +export const MAC_ELECTRON_LANGUAGES = [ + "en", + "de", + "zh_CN", + "zh_TW", + "pt_BR", + "es", + "ru", + "fa", + "ar", + "ja", + "ko", + "pl", + "hu", + "fr", + "uk", + "tr", +] as const; + diff --git a/tools/pack/src/mac/fs.ts b/tools/pack/src/mac/fs.ts new file mode 100644 index 000000000..e8332ed33 --- /dev/null +++ b/tools/pack/src/mac/fs.ts @@ -0,0 +1,70 @@ +import { execFile } from "node:child_process"; +import { access, lstat, readdir, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +function toPosixPath(value: string): string { + return value.replaceAll("\\", "/"); +} + +export async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function sizePathBytes( + path: string, + options: { includeFile?: (path: string) => boolean } = {}, +): Promise { + let metadata: Awaited>; + try { + metadata = await lstat(path); + } catch { + return 0; + } + + if (!metadata.isDirectory()) { + return options.includeFile == null || options.includeFile(toPosixPath(path)) ? metadata.size : 0; + } + + const entries = await readdir(path, { withFileTypes: true }).catch(() => []); + let total = 0; + for (const entry of entries) { + total += await sizePathBytes(join(path, entry.name), options); + } + return total; +} + +export async function sizeExistingFileBytes(path: string): Promise { + try { + const metadata = await stat(path); + return metadata.size; + } catch { + return null; + } +} + +export async function sumChildDirectorySizes(path: string, includeChild: (name: string) => boolean): Promise { + const entries = await readdir(path, { withFileTypes: true }).catch(() => []); + let total = 0; + for (const entry of entries) { + if (!entry.isDirectory() || !includeChild(entry.name)) continue; + total += await sizePathBytes(join(path, entry.name)); + } + return total; +} + + +export async function clearQuarantine(path: string): Promise { + try { + await execFileAsync("xattr", ["-dr", "com.apple.quarantine", path]); + } catch { + // Ignore when the attribute is absent or unsupported for local unsigned artifacts. + } +} diff --git a/tools/pack/src/mac/index.ts b/tools/pack/src/mac/index.ts new file mode 100644 index 000000000..5caab8fed --- /dev/null +++ b/tools/pack/src/mac/index.ts @@ -0,0 +1,23 @@ +export { packMac } from "./build.js"; +export { resolveSeededAppConfigPaths, seedPackagedAppConfig } from "./app-config.js"; +export { + cleanupPackedMacNamespace, + installPackedMacDmg, + inspectPackedMacApp, + readPackedMacLogs, + startPackedMacApp, + stopPackedMacApp, + uninstallPackedMacApp, +} from "./lifecycle.js"; +export type { + ElectronBuilderTarget, + MacCleanupResult, + MacInspectResult, + MacInstallResult, + MacPackResult, + MacPackTiming, + MacSizeReport, + MacStartResult, + MacStopResult, + MacUninstallResult, +} from "./types.js"; diff --git a/tools/pack/src/mac/lifecycle.ts b/tools/pack/src/mac/lifecycle.ts new file mode 100644 index 000000000..1ee723e23 --- /dev/null +++ b/tools/pack/src/mac/lifecycle.ts @@ -0,0 +1,489 @@ +import { execFile } from "node:child_process"; +import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; + +import { + APP_KEYS, + OPEN_DESIGN_SIDECAR_CONTRACT, + SIDECAR_MESSAGES, + SIDECAR_MODES, + SIDECAR_SOURCES, + type DesktopEvalResult, + type DesktopScreenshotResult, + type DesktopStatusSnapshot, + type SidecarStamp, +} from "@open-design/sidecar-proto"; +import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar"; +import { + collectProcessTreePids, + createProcessStampArgs, + listProcessSnapshots, + matchesStampedProcess, + readLogTail, + spawnBackgroundProcess, + stopProcesses, +} from "@open-design/platform"; +import type { ToolPackConfig } from "../config.js"; +import { DESKTOP_LOG_ECHO_ENV, PRODUCT_NAME } from "./constants.js"; +import { clearQuarantine, pathExists } from "./fs.js"; +import { desktopIdentityPath, desktopLogPath, macAppExecutablePath, resolveMacPaths } from "./paths.js"; +import type { DesktopRootIdentityFallback, DesktopRootIdentityMarker, MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js"; + +const execFileAsync = promisify(execFile); + +function desktopStamp(config: ToolPackConfig): SidecarStamp { + return { + app: APP_KEYS.DESKTOP, + ipc: resolveAppIpcPath({ + app: APP_KEYS.DESKTOP, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + namespace: config.namespace, + }), + mode: SIDECAR_MODES.RUNTIME, + namespace: config.namespace, + source: SIDECAR_SOURCES.TOOLS_PACK, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdentityMarker { + if (!isRecord(value)) return false; + return ( + value.version === 1 && + typeof value.pid === "number" && + typeof value.ppid === "number" && + typeof value.appPath === "string" && + typeof value.executablePath === "string" && + typeof value.logPath === "string" && + typeof value.namespaceRoot === "string" && + typeof value.startedAt === "string" && + typeof value.updatedAt === "string" && + isRecord(value.stamp) + ); +} + +function summarizeDesktopMarker( + marker: DesktopRootIdentityMarker | null, +): Partial | undefined { + if (marker == null) return undefined; + return { + appPath: marker.appPath, + executablePath: marker.executablePath, + logPath: marker.logPath, + namespaceRoot: marker.namespaceRoot, + pid: marker.pid, + ppid: marker.ppid, + stamp: marker.stamp, + startedAt: marker.startedAt, + updatedAt: marker.updatedAt, + version: marker.version, + }; +} + +async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{ + fallback: DesktopRootIdentityFallback; + marker: DesktopRootIdentityMarker | null; +}> { + const markerPath = desktopIdentityPath(config); + let payload: unknown; + + try { + payload = JSON.parse(await readFile(markerPath, "utf8")); + } catch (error) { + const code = typeof error === "object" && error != null && "code" in error + ? String((error as { code?: unknown }).code) + : null; + return { + fallback: { + markerPath, + reason: code === "ENOENT" ? "marker-not-found" : "marker-read-failed", + }, + marker: null, + }; + } + + if (!isDesktopRootIdentityMarker(payload)) { + return { + fallback: { + markerPath, + reason: "marker-invalid-shape", + }, + marker: null, + }; + } + + return { + fallback: { + marker: summarizeDesktopMarker(payload), + markerPath, + reason: "marker-present", + }, + marker: payload, + }; +} + +function commandMatchesDesktopMarker( + command: string, + marker: DesktopRootIdentityMarker, +): boolean { + return command.includes(marker.executablePath) || command.includes(macAppExecutablePath(marker.appPath)); +} + +async function resolveDesktopRootIdentityFallback(config: ToolPackConfig): Promise<{ + fallback: DesktopRootIdentityFallback; + rootPid: number | null; +}> { + const { fallback, marker } = await readDesktopRootIdentityMarker(config); + if (marker == null) return { fallback, rootPid: null }; + + let stamp: SidecarStamp; + try { + stamp = OPEN_DESIGN_SIDECAR_CONTRACT.normalizeStamp(marker.stamp); + } catch { + return { + fallback: { ...fallback, reason: "marker-invalid-stamp" }, + rootPid: null, + }; + } + + const expectedIpc = resolveAppIpcPath({ + app: APP_KEYS.DESKTOP, + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + namespace: config.namespace, + }); + if ( + stamp.app !== APP_KEYS.DESKTOP || + stamp.mode !== SIDECAR_MODES.RUNTIME || + stamp.namespace !== config.namespace || + stamp.ipc !== expectedIpc || + (stamp.source !== SIDECAR_SOURCES.PACKAGED && stamp.source !== SIDECAR_SOURCES.TOOLS_PACK) + ) { + return { + fallback: { ...fallback, reason: "marker-stamp-mismatch" }, + rootPid: null, + }; + } + + if (marker.namespaceRoot !== config.roots.runtime.namespaceRoot) { + return { + fallback: { ...fallback, reason: "marker-namespace-root-mismatch" }, + rootPid: null, + }; + } + + const processes = await listProcessSnapshots(); + const processInfo = processes.find((entry) => entry.pid === marker.pid) ?? null; + if (processInfo == null) { + return { + fallback: { ...fallback, reason: "marker-pid-not-running" }, + rootPid: null, + }; + } + + if (!commandMatchesDesktopMarker(processInfo.command, marker)) { + return { + fallback: { + ...fallback, + processCommand: processInfo.command, + reason: "marker-command-mismatch", + }, + rootPid: null, + }; + } + + return { + fallback: { + ...fallback, + processCommand: processInfo.command, + reason: "marker-matched", + }, + rootPid: marker.pid, + }; +} + +function isUnmanagedDesktopFallback(fallback: DesktopRootIdentityFallback | undefined): boolean { + return fallback != null && ![ + "marker-matched", + "marker-not-found", + "marker-pid-not-running", + ].includes(fallback.reason); +} + +async function waitForDesktopStatus(config: ToolPackConfig, timeoutMs = 45_000): Promise { + const stamp = desktopStamp(config); + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + try { + return await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 1000 }); + } catch { + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + } + return null; +} + +async function resolvePackedMacStartTarget(config: ToolPackConfig): Promise<{ + appPath: string; + executablePath: string; + source: MacStartSource; +}> { + const paths = resolveMacPaths(config); + const candidates: Array<{ appPath: string; source: MacStartSource }> = [ + { appPath: paths.installedAppPath, source: "installed" }, + { appPath: paths.userApplicationsAppPath, source: "user-applications" }, + { appPath: paths.systemApplicationsAppPath, source: "system-applications" }, + { appPath: paths.appPath, source: "built" }, + ]; + + for (const candidate of candidates) { + const executablePath = macAppExecutablePath(candidate.appPath); + if (await pathExists(executablePath)) { + return { ...candidate, executablePath }; + } + } + + throw new Error( + `no mac .app executable found for namespace=${config.namespace}; run tools-pack mac build --to all and tools-pack mac install first`, + ); +} + +async function detachMount(mountPoint: string): Promise { + try { + await execFileAsync("hdiutil", ["detach", mountPoint, "-quiet"]); + return true; + } catch { + try { + await execFileAsync("hdiutil", ["detach", mountPoint, "-force", "-quiet"]); + return true; + } catch { + return false; + } + } +} + +export async function installPackedMacDmg(config: ToolPackConfig): Promise { + const paths = resolveMacPaths(config); + if (!(await pathExists(paths.dmgPath))) { + throw new Error(`no mac dmg found at ${paths.dmgPath}; run tools-pack mac build --to all first`); + } + + await rm(paths.mountPoint, { force: true, recursive: true }); + await mkdir(paths.mountPoint, { recursive: true }); + await rm(paths.installedAppPath, { force: true, recursive: true }); + await mkdir(paths.installApplicationsRoot, { recursive: true }); + + let detached = false; + try { + await execFileAsync("hdiutil", [ + "attach", + paths.dmgPath, + "-mountpoint", + paths.mountPoint, + "-nobrowse", + "-quiet", + ]); + await execFileAsync("ditto", [join(paths.mountPoint, `${PRODUCT_NAME}.app`), paths.installedAppPath]); + await clearQuarantine(paths.installedAppPath); + } finally { + detached = await detachMount(paths.mountPoint); + } + + return { + detached, + dmgPath: paths.dmgPath, + installedAppPath: paths.installedAppPath, + mountPoint: paths.mountPoint, + namespace: config.namespace, + }; +} + +export async function startPackedMacApp(config: ToolPackConfig): Promise { + const target = await resolvePackedMacStartTarget(config); + const stamp = desktopStamp(config); + const logPath = desktopLogPath(config); + await mkdir(dirname(logPath), { recursive: true }); + await writeFile(logPath, "", "utf8"); + + const spawned = await spawnBackgroundProcess({ + args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT), + command: target.executablePath, + cwd: target.appPath, + env: createSidecarLaunchEnv({ + base: join(config.roots.runtime.namespaceRoot, "runtime"), + contract: OPEN_DESIGN_SIDECAR_CONTRACT, + extraEnv: { + ...process.env, + [DESKTOP_LOG_ECHO_ENV]: "0", + }, + stamp, + }), + logFd: null, + }); + const status = await waitForDesktopStatus(config); + return { + appPath: target.appPath, + executablePath: target.executablePath, + logPath, + namespace: config.namespace, + pid: spawned.pid, + source: target.source, + status, + }; +} + +async function findManagedDesktopProcessTree(config: ToolPackConfig): Promise<{ + fallback?: DesktopRootIdentityFallback; + pids: number[]; +}> { + const processes = await listProcessSnapshots(); + const stampedRootPids = processes + .filter((processInfo) => + matchesStampedProcess(processInfo, { + mode: SIDECAR_MODES.RUNTIME, + namespace: config.namespace, + source: SIDECAR_SOURCES.TOOLS_PACK, + }, OPEN_DESIGN_SIDECAR_CONTRACT), + ) + .map((processInfo) => processInfo.pid); + const identity = await resolveDesktopRootIdentityFallback(config); + const pids = collectProcessTreePids(processes, [ + ...stampedRootPids, + identity.rootPid, + ]); + return { fallback: identity.fallback, pids }; +} + +async function waitForNoManagedDesktopProcesses( + config: ToolPackConfig, + timeoutMs = 6000, +): Promise<{ fallback?: DesktopRootIdentityFallback; pids: number[] }> { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const current = await findManagedDesktopProcessTree(config); + if (current.pids.length === 0) return current; + await new Promise((resolveWait) => setTimeout(resolveWait, 150)); + } + return await findManagedDesktopProcessTree(config); +} + +export async function stopPackedMacApp(config: ToolPackConfig): Promise { + const stamp = desktopStamp(config); + const before = await findManagedDesktopProcessTree(config); + let gracefulRequested = false; + + try { + await requestJsonIpc(stamp.ipc, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 }); + gracefulRequested = true; + } catch { + gracefulRequested = false; + } + + const remainingAfterGraceful = gracefulRequested ? await waitForNoManagedDesktopProcesses(config) : before; + if (remainingAfterGraceful.pids.length === 0) { + const unmanaged = !gracefulRequested && before.pids.length === 0 && isUnmanagedDesktopFallback(before.fallback); + if (!unmanaged) { + await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); + } + return { + ...(before.fallback == null ? {} : { fallback: before.fallback }), + gracefulRequested, + namespace: config.namespace, + remainingPids: [], + status: unmanaged ? "unmanaged" : before.pids.length === 0 ? "not-running" : "stopped", + stoppedPids: before.pids, + }; + } + + const stopped = await stopProcesses(remainingAfterGraceful.pids); + if (stopped.remainingPids.length === 0) { + await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined); + } + return { + ...(remainingAfterGraceful.fallback == null ? {} : { fallback: remainingAfterGraceful.fallback }), + gracefulRequested, + namespace: config.namespace, + remainingPids: stopped.remainingPids, + status: stopped.remainingPids.length === 0 ? "stopped" : "partial", + stoppedPids: stopped.stoppedPids, + }; +} + +export async function readPackedMacLogs(config: ToolPackConfig) { + const entries = await Promise.all( + [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON].map(async (app) => { + const logPath = join(config.roots.runtime.namespaceRoot, "logs", app, "latest.log"); + return [app, { lines: await readLogTail(logPath, 200), logPath }] as const; + }), + ); + + return { + logs: Object.fromEntries(entries), + namespace: config.namespace, + }; +} + +export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise { + const stamp = desktopStamp(config); + const status = await requestJsonIpc( + stamp.ipc, + { type: SIDECAR_MESSAGES.STATUS }, + { timeoutMs: 2000 }, + ).catch(() => null); + + return { + ...(options.expr == null ? {} : { + eval: await requestJsonIpc( + stamp.ipc, + { input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL }, + { timeoutMs: 5000 }, + ), + }), + ...(options.path == null ? {} : { + screenshot: await requestJsonIpc( + stamp.ipc, + { input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT }, + { timeoutMs: 10000 }, + ), + }), + status, + }; +} + +export async function uninstallPackedMacApp(config: ToolPackConfig): Promise { + const paths = resolveMacPaths(config); + const stop = await stopPackedMacApp(config); + const removed = await pathExists(paths.installedAppPath); + await rm(paths.installedAppPath, { force: true, recursive: true }); + + return { + installedAppPath: paths.installedAppPath, + namespace: config.namespace, + removed, + stop, + }; +} + +export async function cleanupPackedMacNamespace(config: ToolPackConfig): Promise { + const paths = resolveMacPaths(config); + const stop = await stopPackedMacApp(config); + const detachedMount = await detachMount(paths.mountPoint); + const removedOutputRoot = await pathExists(config.roots.output.namespaceRoot); + const removedRuntimeNamespaceRoot = await pathExists(config.roots.runtime.namespaceRoot); + + await rm(config.roots.output.namespaceRoot, { force: true, recursive: true }); + await rm(config.roots.runtime.namespaceRoot, { force: true, recursive: true }); + + return { + detachedMount, + namespace: config.namespace, + outputRoot: config.roots.output.namespaceRoot, + removedOutputRoot, + removedRuntimeNamespaceRoot, + runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, + stop, + }; +} diff --git a/tools/pack/src/mac/manifest.ts b/tools/pack/src/mac/manifest.ts new file mode 100644 index 000000000..6fff548fe --- /dev/null +++ b/tools/pack/src/mac/manifest.ts @@ -0,0 +1,13 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; + +export async function readPackagedVersion(config: ToolPackConfig): Promise { + const packageJsonPath = join(config.workspaceRoot, "apps", "packaged", "package.json"); + const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as { version?: unknown }; + if (typeof packageJson.version !== "string" || packageJson.version.length === 0) { + throw new Error(`missing apps/packaged package version in ${packageJsonPath}`); + } + return packageJson.version; +} diff --git a/tools/pack/src/mac/paths.ts b/tools/pack/src/mac/paths.ts new file mode 100644 index 000000000..727e287f1 --- /dev/null +++ b/tools/pack/src/mac/paths.ts @@ -0,0 +1,88 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { APP_KEYS } from "@open-design/sidecar-proto"; + +import type { ToolPackConfig } from "../config.js"; +import { PRODUCT_NAME } from "./constants.js"; +import { + MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME, + MAC_PREBUNDLE_META_DIR_NAME, + MAC_PREBUNDLED_APP_DIR_NAME, + MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH, + MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH, + MAC_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH, + MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH, +} from "../mac-prebundle.js"; +import type { MacPaths } from "./types.js"; + +export function sanitizeNamespace(value: string): string { + return value.replace(/[^A-Za-z0-9._-]+/g, "-"); +} + +export function macAppBundleName(namespace: string): string { + return `${PRODUCT_NAME}.${sanitizeNamespace(namespace)}.app`; +} + +export function macAppExecutablePath(appPath: string): string { + return join(appPath, "Contents", "MacOS", PRODUCT_NAME); +} + +export function resolveMacAppOutputDirectoryName(): string { + return process.arch === "arm64" ? "mac-arm64" : "mac"; +} + +export function resolveMacPaths(config: ToolPackConfig): MacPaths { + const namespaceRoot = config.roots.output.namespaceRoot; + const appBuilderOutputRoot = config.roots.output.appBuilderRoot; + const namespaceToken = sanitizeNamespace(config.namespace); + const appPath = join( + appBuilderOutputRoot, + resolveMacAppOutputDirectoryName(), + `${PRODUCT_NAME}.app`, + ); + const installApplicationsRoot = join(namespaceRoot, "install", "Applications"); + const installedAppPath = join(installApplicationsRoot, macAppBundleName(config.namespace)); + + return { + appBuilderConfigPath: join(namespaceRoot, "builder-config.json"), + appBuilderOutputRoot, + appPath, + assembledAppRoot: join(namespaceRoot, "assembled", "app"), + assembledMainEntryPath: join(namespaceRoot, "assembled", "app", "main.cjs"), + assembledPackageJsonPath: join(namespaceRoot, "assembled", "app", "package.json"), + assembledPrebundledRoot: join(namespaceRoot, "assembled", "app", MAC_PREBUNDLED_APP_DIR_NAME), + daemonCliPrebundleEntrypointPath: join(namespaceRoot, MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME, "daemon-cli.js"), + daemonCliPrebundlePath: join(namespaceRoot, "assembled", MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH), + daemonPrebundleMetaPath: join(namespaceRoot, MAC_PREBUNDLE_META_DIR_NAME, "daemon.meta.json"), + daemonPrebundleRoot: join(namespaceRoot, "assembled", "app", MAC_PREBUNDLED_APP_DIR_NAME, "daemon"), + daemonSidecarPrebundleEntrypointPath: join(namespaceRoot, MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME, "daemon-sidecar.js"), + daemonSidecarPrebundlePath: join(namespaceRoot, "assembled", MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH), + dmgPath: join(namespaceRoot, "dmg", `${PRODUCT_NAME}-${namespaceToken}.dmg`), + installApplicationsRoot, + installedAppPath, + latestMacYmlPath: join(namespaceRoot, "zip", "latest-mac.yml"), + mountPoint: join(namespaceRoot, "mount"), + packagedMainPrebundleMetaPath: join(namespaceRoot, MAC_PREBUNDLE_META_DIR_NAME, "packaged-main.meta.json"), + packagedMainPrebundlePath: join(namespaceRoot, "assembled", MAC_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH), + packagedConfigPath: join(namespaceRoot, "open-design-config.json"), + resourceRoot: join(namespaceRoot, "resources", "open-design"), + systemApplicationsAppPath: join("/Applications", macAppBundleName(config.namespace)), + tarballsRoot: join(namespaceRoot, "tarballs"), + userApplicationsAppPath: join(homedir(), "Applications", macAppBundleName(config.namespace)), + webStandaloneHookAuditPath: join(namespaceRoot, "web-standalone-after-pack-audit.json"), + webStandaloneHookConfigPath: join(namespaceRoot, "web-standalone-after-pack-config.json"), + webSidecarPrebundleMetaPath: join(namespaceRoot, MAC_PREBUNDLE_META_DIR_NAME, "web-sidecar.meta.json"), + webSidecarPrebundlePath: join(namespaceRoot, "assembled", MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH), + zipPath: join(namespaceRoot, "zip", `${PRODUCT_NAME}-${namespaceToken}.zip`), + }; +} + + +export function desktopLogPath(config: ToolPackConfig): string { + return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log"); +} + +export function desktopIdentityPath(config: ToolPackConfig): string { + return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json"); +} diff --git a/tools/pack/src/mac/report.ts b/tools/pack/src/mac/report.ts new file mode 100644 index 000000000..3de0497ea --- /dev/null +++ b/tools/pack/src/mac/report.ts @@ -0,0 +1,85 @@ +import { join } from "node:path"; + +import type { ToolPackConfig } from "../config.js"; +import { MAC_PREBUNDLED_APP_DIR_NAME } from "../mac-prebundle.js"; +import { + ELECTRON_BUILDER_ASAR, + ELECTRON_BUILDER_FILE_PATTERNS, + MAC_ELECTRON_LANGUAGES, + WEB_STANDALONE_RESOURCE_NAME, +} from "./constants.js"; +import { sizeExistingFileBytes, sizePathBytes, sumChildDirectorySizes } from "./fs.js"; +import type { ElectronBuilderTarget, MacPackResult, MacPaths, MacSizeReport } from "./types.js"; + +function resolveDarwinArchToken(): "arm64" | "x64" { + return process.arch === "arm64" ? "arm64" : "x64"; +} + +function isBetterSqlite3SourceResidue(path: string): boolean { + return ( + path.includes("/node_modules/better-sqlite3/deps/") || + path.includes("/node_modules/better-sqlite3/build/Release/obj/") + ); +} + +export async function collectMacSizeReport( + config: ToolPackConfig, + paths: MacPaths, + artifacts: Pick, + targets: ElectronBuilderTarget[], +): Promise { + const appResourcesRoot = join(paths.appPath, "Contents", "Resources"); + const appNodeModulesRoot = join(appResourcesRoot, "app", "node_modules"); + const electronFrameworksRoot = join(paths.appPath, "Contents", "Frameworks"); + const electronFrameworkResourcesRoot = join( + electronFrameworksRoot, + "Electron Framework.framework", + "Versions", + "A", + "Resources", + ); + const darwinArch = resolveDarwinArchToken(); + + return { + appBytes: await sizePathBytes(paths.appPath), + builder: { + asar: ELECTRON_BUILDER_ASAR, + compression: config.macCompression, + electronLanguages: MAC_ELECTRON_LANGUAGES, + filePatterns: ELECTRON_BUILDER_FILE_PATTERNS, + targets, + webOutputMode: config.webOutputMode, + }, + dmgBytes: artifacts.dmgPath == null ? null : await sizeExistingFileBytes(artifacts.dmgPath), + generatedAt: new Date().toISOString(), + outputRootBytes: await sizePathBytes(config.roots.output.namespaceRoot), + resourceRootBytes: await sizePathBytes(paths.resourceRoot), + runtimeNamespaceRoot: config.roots.runtime.namespaceRoot, + topLevel: { + appResourcesBytes: await sizePathBytes(join(appResourcesRoot, "app")), + electronFrameworksBytes: await sizePathBytes(electronFrameworksRoot), + resourcesBytes: await sizePathBytes(appResourcesRoot), + }, + tracked: { + appNodeModulesBytes: await sizePathBytes(appNodeModulesRoot), + betterSqlite3Bytes: await sizePathBytes(join(appNodeModulesRoot, "better-sqlite3")), + betterSqlite3SourceResidueBytes: await sizePathBytes(paths.appPath, { + includeFile: isBetterSqlite3SourceResidue, + }), + bundledNodeBytes: await sizePathBytes(join(paths.resourceRoot, "bin", "node")), + electronLocalesBytes: await sumChildDirectorySizes(electronFrameworkResourcesRoot, (name) => name.endsWith(".lproj")), + markdownBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".md") }), + nextBytes: await sizePathBytes(join(appNodeModulesRoot, "next")), + nextSwcBytes: await sumChildDirectorySizes(join(appNodeModulesRoot, "@next"), (name) => name.startsWith("swc-darwin-")), + prebundledRuntimeBytes: await sizePathBytes(join(appResourcesRoot, "app", MAC_PREBUNDLED_APP_DIR_NAME)), + sharpLibvipsBytes: await sizePathBytes(join(appNodeModulesRoot, "@img", `sharp-libvips-darwin-${darwinArch}`)), + sourcemapBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".map") }), + tsbuildInfoBytes: await sizePathBytes(paths.appPath, { includeFile: (path) => path.endsWith(".tsbuildinfo") }), + webCopiedStandaloneBytes: await sizePathBytes(join(appResourcesRoot, WEB_STANDALONE_RESOURCE_NAME)), + webNextCacheBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web", ".next", "cache")), + webPackageBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web")), + webPackageStandaloneBytes: await sizePathBytes(join(appNodeModulesRoot, "@open-design", "web", ".next", "standalone")), + }, + zipBytes: artifacts.zipPath == null ? null : await sizeExistingFileBytes(artifacts.zipPath), + }; +} diff --git a/tools/pack/src/mac/types.ts b/tools/pack/src/mac/types.ts new file mode 100644 index 000000000..70d4da72e --- /dev/null +++ b/tools/pack/src/mac/types.ts @@ -0,0 +1,183 @@ +import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, SidecarStamp } from "@open-design/sidecar-proto"; +import type { CacheReport } from "../cache.js"; +import type { ToolPackBuildOutput, ToolPackConfig } from "../config.js"; +import type { INTERNAL_PACKAGES } from "./constants.js"; + +export type PackedTarballInfo = { + fileName: string; + packageName: (typeof INTERNAL_PACKAGES)[number]["name"]; +}; + +export type MacPaths = { + appBuilderConfigPath: string; + appBuilderOutputRoot: string; + appPath: string; + assembledAppRoot: string; + assembledMainEntryPath: string; + assembledPackageJsonPath: string; + assembledPrebundledRoot: string; + daemonCliPrebundleEntrypointPath: string; + daemonCliPrebundlePath: string; + daemonPrebundleMetaPath: string; + daemonPrebundleRoot: string; + daemonSidecarPrebundleEntrypointPath: string; + daemonSidecarPrebundlePath: string; + dmgPath: string; + installApplicationsRoot: string; + installedAppPath: string; + latestMacYmlPath: string; + mountPoint: string; + packagedMainPrebundleMetaPath: string; + packagedMainPrebundlePath: string; + packagedConfigPath: string; + resourceRoot: string; + systemApplicationsAppPath: string; + tarballsRoot: string; + userApplicationsAppPath: string; + webStandaloneHookAuditPath: string; + webStandaloneHookConfigPath: string; + webSidecarPrebundleMetaPath: string; + webSidecarPrebundlePath: string; + zipPath: string; +}; + +export type SeededAppConfigPaths = { + sourcePath: string; + targetPath: string; +}; + +export type MacPackResult = { + appPath: string; + cacheReport: CacheReport; + dmgPath: string | null; + latestMacYmlPath: string | null; + outputRoot: string; + resourceRoot: string; + runtimeNamespaceRoot: string; + sizeReport: MacSizeReport; + timings: MacPackTiming[]; + to: ToolPackBuildOutput; + zipPath: string | null; +}; + +export type MacPackTiming = { + durationMs: number; + phase: string; +}; + +export type MacStartSource = "built" | "installed" | "system-applications" | "user-applications"; + +export type MacStartResult = { + appPath: string; + executablePath: string; + logPath: string; + namespace: string; + pid: number; + source: MacStartSource; + status: DesktopStatusSnapshot | null; +}; + +export type MacInspectResult = { + eval?: DesktopEvalResult; + screenshot?: DesktopScreenshotResult; + status: DesktopStatusSnapshot | null; +}; + +export type DesktopRootIdentityMarker = { + appPath: string; + executablePath: string; + logPath: string; + namespaceRoot: string; + pid: number; + ppid: number; + stamp: SidecarStamp; + startedAt: string; + updatedAt: string; + version: 1; +}; + +export type DesktopRootIdentityFallback = { + marker?: Partial; + markerPath: string; + processCommand?: string; + reason: string; +}; + +export type MacStopResult = { + fallback?: DesktopRootIdentityFallback; + gracefulRequested: boolean; + namespace: string; + remainingPids: number[]; + status: "not-running" | "partial" | "stopped" | "unmanaged"; + stoppedPids: number[]; +}; + +export type MacInstallResult = { + detached: boolean; + dmgPath: string; + installedAppPath: string; + mountPoint: string; + namespace: string; +}; + +export type MacUninstallResult = { + installedAppPath: string; + namespace: string; + removed: boolean; + stop: MacStopResult; +}; + +export type MacCleanupResult = { + detachedMount: boolean; + namespace: string; + outputRoot: string; + removedOutputRoot: boolean; + removedRuntimeNamespaceRoot: boolean; + runtimeNamespaceRoot: string; + stop: MacStopResult; +}; + +export type ElectronBuilderTarget = "dir" | "dmg" | "zip"; + +export type MacSizeReport = { + appBytes: number; + builder: { + asar: boolean; + compression: ToolPackConfig["macCompression"]; + electronLanguages: readonly string[]; + filePatterns: readonly string[]; + targets: ElectronBuilderTarget[]; + webOutputMode: ToolPackConfig["webOutputMode"]; + }; + dmgBytes: number | null; + generatedAt: string; + outputRootBytes: number; + resourceRootBytes: number; + runtimeNamespaceRoot: string; + topLevel: { + appResourcesBytes: number; + electronFrameworksBytes: number; + resourcesBytes: number; + }; + tracked: { + appNodeModulesBytes: number; + betterSqlite3Bytes: number; + betterSqlite3SourceResidueBytes: number; + bundledNodeBytes: number; + electronLocalesBytes: number; + markdownBytes: number; + nextBytes: number; + nextSwcBytes: number; + prebundledRuntimeBytes: number; + sharpLibvipsBytes: number; + sourcemapBytes: number; + tsbuildInfoBytes: number; + webCopiedStandaloneBytes: number; + webNextCacheBytes: number; + webPackageBytes: number; + webPackageStandaloneBytes: number; + }; + zipBytes: number | null; +}; + +export type MacBuildOutput = Extract; diff --git a/tools/pack/src/mac/workspace.ts b/tools/pack/src/mac/workspace.ts new file mode 100644 index 000000000..54c5f3eee --- /dev/null +++ b/tools/pack/src/mac/workspace.ts @@ -0,0 +1,38 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { ToolPackCache } from "../cache.js"; +import type { ToolPackConfig } from "../config.js"; +import { ensureWorkspaceBuildArtifacts } from "../workspace-build.js"; +import { runPnpm } from "./commands.js"; + +async function buildWorkspaceArtifacts(config: ToolPackConfig): Promise { + const webNextEnvPath = join(config.workspaceRoot, "apps", "web", "next-env.d.ts"); + const previousWebNextEnv = await readFile(webNextEnvPath, "utf8").catch(() => null); + + await runPnpm(config, ["--filter", "@open-design/contracts", "build"]); + await runPnpm(config, ["--filter", "@open-design/sidecar-proto", "build"]); + await runPnpm(config, ["--filter", "@open-design/sidecar", "build"]); + await runPnpm(config, ["--filter", "@open-design/platform", "build"]); + await runPnpm(config, ["--filter", "@open-design/daemon", "build"]); + try { + await runPnpm(config, ["--filter", "@open-design/web", "build"], { + OD_WEB_OUTPUT_MODE: config.webOutputMode, + }); + await runPnpm(config, ["--filter", "@open-design/web", "build:sidecar"]); + } finally { + if (previousWebNextEnv == null) { + await rm(webNextEnvPath, { force: true }); + } else { + await writeFile(webNextEnvPath, previousWebNextEnv, "utf8"); + } + } + await runPnpm(config, ["--filter", "@open-design/desktop", "build"]); + await runPnpm(config, ["--filter", "@open-design/packaged", "build"]); +} + +export async function ensureMacWorkspaceBuild(config: ToolPackConfig, cache: ToolPackCache): Promise { + await ensureWorkspaceBuildArtifacts(config, cache, async () => { + await buildWorkspaceArtifacts(config); + }); +} diff --git a/tools/pack/src/workspace-build.ts b/tools/pack/src/workspace-build.ts index a30274ab3..f0178e777 100644 --- a/tools/pack/src/workspace-build.ts +++ b/tools/pack/src/workspace-build.ts @@ -64,10 +64,11 @@ async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise Promise, ): Promise { const key = await createWorkspaceBuildCacheKey(config); + const nodeId = `${config.platform}.workspace-build`; const artifacts = workspaceBuildArtifacts(config); await cache.acquire({ materialize: artifacts.map((artifact) => ({ @@ -162,7 +164,7 @@ export async function ensureWorkspaceBuildArtifacts( to: join(config.workspaceRoot, artifact.workspacePath), })), node: { - id: "win.workspace-build", + id: nodeId, key, outputs: ["stamp.json", ...artifacts.map((artifact) => artifact.cachePath)], invalidate: async () => null, diff --git a/tools/pack/tests/mac-prebundle.test.ts b/tools/pack/tests/mac-prebundle.test.ts new file mode 100644 index 000000000..281f6e73a --- /dev/null +++ b/tools/pack/tests/mac-prebundle.test.ts @@ -0,0 +1,145 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER, + MAC_PREBUNDLE_ESBUILD_TARGET, + MAC_PREBUNDLE_POLICIES, + MAC_PREBUNDLE_RUNTIME_DEPENDENCIES, + MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH, + MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH, + MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH, + assertMacPrebundleMetafile, + findForbiddenMacPrebundleInputs, + renderMacPackagedMainEntry, + shouldInstallInternalPackageForMacPrebundle, + shouldUseMacStandalonePrebundle, +} from "../src/mac-prebundle.js"; + +describe("mac standalone prebundle policy", () => { + it("is enabled only for standalone web output", () => { + expect(shouldUseMacStandalonePrebundle("standalone")).toBe(true); + expect(shouldUseMacStandalonePrebundle("server")).toBe(false); + }); + + it("keeps server-mode package topology unchanged", () => { + expect( + shouldInstallInternalPackageForMacPrebundle({ + packageName: "@open-design/web", + webOutputMode: "server", + }), + ).toBe(true); + expect( + shouldInstallInternalPackageForMacPrebundle({ + packageName: "@open-design/packaged", + webOutputMode: "server", + }), + ).toBe(true); + }); + + it("excludes internal packages replaced by mac standalone prebundles", () => { + for (const packageName of [ + "@open-design/contracts", + "@open-design/daemon", + "@open-design/desktop", + "@open-design/packaged", + "@open-design/platform", + "@open-design/sidecar", + "@open-design/sidecar-proto", + "@open-design/web", + ]) { + expect( + shouldInstallInternalPackageForMacPrebundle({ + packageName, + webOutputMode: "standalone", + }), + ).toBe(false); + } + }); + + it("documents the explicit code-level bundle boundaries", () => { + expect(MAC_PREBUNDLE_ESBUILD_TARGET).toBe("node24"); + expect(MAC_PREBUNDLE_POLICIES.packagedMain.externals).toEqual(["electron"]); + expect(MAC_PREBUNDLE_POLICIES.daemonCli.externals).toEqual(["better-sqlite3"]); + expect(MAC_PREBUNDLE_POLICIES.daemonSidecar.externals).toEqual(["better-sqlite3"]); + expect(MAC_PREBUNDLE_POLICIES.webSidecar.externals).toEqual([]); + expect(MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER).toContain("createRequire"); + expect(MAC_PREBUNDLE_RUNTIME_DEPENDENCIES).toEqual({ "better-sqlite3": "12.9.0" }); + expect(MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-cli.mjs"); + expect(MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/daemon/daemon-sidecar.mjs"); + expect(MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH).toBe("app/prebundled/web-sidecar.mjs"); + }); +}); + +describe("findForbiddenMacPrebundleInputs", () => { + it("matches forbidden dependency roots after path normalization", () => { + expect( + findForbiddenMacPrebundleInputs({ + forbiddenInputs: MAC_PREBUNDLE_POLICIES.webSidecar.forbiddenInputs, + inputs: [ + "src/index.ts", + "C:\\repo\\node_modules\\next\\dist\\server.js", + "/repo/node_modules/openai/index.mjs", + ], + }), + ).toEqual([ + "C:/repo/node_modules/next/dist/server.js", + "/repo/node_modules/openai/index.mjs", + ]); + }); +}); + +describe("assertMacPrebundleMetafile", () => { + it("accepts a safe web sidecar metafile", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-mac-prebundle-")); + const metafilePath = join(root, "safe.json"); + + try { + await writeFile( + metafilePath, + JSON.stringify({ inputs: { "/repo/apps/web/sidecar/index.ts": {} } }), + "utf8", + ); + + await expect( + assertMacPrebundleMetafile({ metafilePath, policyName: "webSidecar" }), + ).resolves.toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("rejects a packaged main metafile that pulled in web runtime closure", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-mac-prebundle-")); + const metafilePath = join(root, "unsafe.json"); + + try { + await writeFile( + metafilePath, + JSON.stringify({ inputs: { "/repo/node_modules/@open-design/web/dist/sidecar/index.js": {} } }), + "utf8", + ); + + await expect( + assertMacPrebundleMetafile({ metafilePath, policyName: "packagedMain" }), + ).rejects.toThrow(/packaged main prebundle included forbidden inputs/); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); + +describe("renderMacPackagedMainEntry", () => { + it("renders the prebundled runtime entry shim", () => { + expect(renderMacPackagedMainEntry(true)).toContain("./prebundled/packaged-main.mjs"); + expect(renderMacPackagedMainEntry(true)).not.toContain("@open-design/packaged"); + }); + + it("renders the package entry shim for non-prebundled mode", () => { + expect(renderMacPackagedMainEntry(false)).toContain("@open-design/packaged"); + expect(renderMacPackagedMainEntry(false)).not.toContain("./prebundled/packaged-main.mjs"); + }); +}); diff --git a/tools/pack/tests/mac.test.ts b/tools/pack/tests/mac.test.ts index f2ee67a32..7e6d8ffbe 100644 --- a/tools/pack/tests/mac.test.ts +++ b/tools/pack/tests/mac.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { ToolPackConfig } from "../src/config.js"; -import { resolveSeededAppConfigPaths, seedPackagedAppConfig } from "../src/mac.js"; +import { resolveSeededAppConfigPaths, seedPackagedAppConfig } from "../src/mac/index.js"; function makeConfig(root: string, overrides: Partial = {}): ToolPackConfig { return { diff --git a/tools/pack/tests/web-standalone-after-pack.test.ts b/tools/pack/tests/web-standalone-after-pack.test.ts index bada4ce61..0266099c3 100644 --- a/tools/pack/tests/web-standalone-after-pack.test.ts +++ b/tools/pack/tests/web-standalone-after-pack.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import path, { join } from "node:path"; @@ -29,6 +29,16 @@ async function writePackage(packageRoot: string, packageName: string): Promise { + const packageDirectoryName = `${packageName}@0.0.0`.split("/").join("+"); + const packageRoot = join(standaloneRoot, "node_modules", ".pnpm", packageDirectoryName, "node_modules", packageName); + const hoistPath = join(standaloneRoot, "node_modules", ".pnpm", "node_modules", packageName); + await writePackage(packageRoot, packageName); + await mkdir(path.dirname(hoistPath), { recursive: true }); + await symlink(packageRoot, hoistPath); + return packageRoot; +} + async function writeRootWebPackage(resourcesRoot: string): Promise { const webPackageRoot = join(resourcesRoot, "app", "node_modules", "@open-design", "web"); await mkdir(join(webPackageRoot, "dist", "sidecar"), { recursive: true }); @@ -38,34 +48,49 @@ async function writeRootWebPackage(resourcesRoot: string): Promise { async function writeStandaloneFixture( workspaceRoot: string, - options: { includeHoistedNext: boolean; includeWebNext: boolean }, + options: { includeHoistedNext: boolean; includeWebNext: boolean; useAbsolutePnpmSymlinks?: boolean }, ): Promise { const standaloneRoot = join(workspaceRoot, "apps", "web", ".next", "standalone"); const sourceWebRoot = join(standaloneRoot, "apps", "web"); const hoistRoot = join(standaloneRoot, "node_modules", ".pnpm", "node_modules"); - if (options.includeHoistedNext) { - await writePackage(join(hoistRoot, "next"), "next"); + if (options.useAbsolutePnpmSymlinks) { + const nextPackageRoot = options.includeHoistedNext ? await writePnpmLinkedPackage(standaloneRoot, "next") : null; + await writePnpmLinkedPackage(standaloneRoot, "react"); + await writePnpmLinkedPackage(standaloneRoot, "react-dom"); + await writePnpmLinkedPackage(standaloneRoot, "styled-jsx"); + if (options.includeWebNext && nextPackageRoot != null) { + await mkdir(join(sourceWebRoot, "node_modules"), { recursive: true }); + await symlink(nextPackageRoot, join(sourceWebRoot, "node_modules", "next")); + } + } else { + if (options.includeHoistedNext) { + await writePackage(join(hoistRoot, "next"), "next"); + } + await writePackage(join(hoistRoot, "react"), "react"); + await writePackage(join(hoistRoot, "react-dom"), "react-dom"); + await writePackage(join(hoistRoot, "styled-jsx"), "styled-jsx"); + if (options.includeWebNext) { + await writePackage(join(sourceWebRoot, "node_modules", "next"), "next"); + } } - await writePackage(join(hoistRoot, "react"), "react"); - await writePackage(join(hoistRoot, "react-dom"), "react-dom"); - await writePackage(join(hoistRoot, "styled-jsx"), "styled-jsx"); await mkdir(join(sourceWebRoot, ".next", "static"), { recursive: true }); await writeFile(join(sourceWebRoot, "server.js"), "module.exports = {};\n", "utf8"); await writeFile(join(sourceWebRoot, ".next", "BUILD_ID"), "fixture\n", "utf8"); - if (options.includeWebNext) { - await writePackage(join(sourceWebRoot, "node_modules", "next"), "next"); - } - await mkdir(join(workspaceRoot, "apps", "web", ".next", "static"), { recursive: true }); await writeFile(join(workspaceRoot, "apps", "web", ".next", "static", "client.js"), "client();\n", "utf8"); return standaloneRoot; } -async function runFixture(options: { includeHoistedNext?: boolean; includeWebNext: boolean }): Promise<{ +async function runFixture(options: { + includeHoistedNext?: boolean; + includeWebNext: boolean; + platformName?: "darwin" | "win32"; + useAbsolutePnpmSymlinks?: boolean; +}): Promise<{ appOutDir: string; auditReportPath: string; destinationRoot: string; @@ -76,9 +101,13 @@ async function runFixture(options: { includeHoistedNext?: boolean; includeWebNex const standaloneSourceRoot = await writeStandaloneFixture(workspaceRoot, { includeHoistedNext: options.includeHoistedNext ?? true, includeWebNext: options.includeWebNext, + useAbsolutePnpmSymlinks: options.useAbsolutePnpmSymlinks, }); - const appOutDir = join(root, "builder", "win-unpacked"); - const resourcesRoot = join(appOutDir, "resources"); + const platformName = options.platformName ?? "win32"; + const appOutDir = join(root, "builder", platformName === "darwin" ? "mac-arm64" : "win-unpacked"); + const resourcesRoot = platformName === "darwin" + ? join(appOutDir, "Open Design.app", "Contents", "Resources") + : join(appOutDir, "resources"); const auditReportPath = join(root, "audit.json"); const configPath = join(root, "config.json"); const oldConfigEnv = process.env[CONFIG_ENV]; @@ -110,7 +139,7 @@ async function runFixture(options: { includeHoistedNext?: boolean; includeWebNex try { await runWebStandaloneAfterPack({ appOutDir, - electronPlatformName: "win32", + electronPlatformName: platformName, packager: { appInfo: { productFilename: "Open Design" } }, }); } catch (error) { @@ -173,4 +202,28 @@ describe("web standalone afterPack hook", () => { /copied standalone app-local Next package missing/, ); }); + + it("rewrites darwin copied pnpm symlinks to stay inside the packaged resource", async () => { + const fixture = await runFixture({ + includeWebNext: true, + platformName: "darwin", + useAbsolutePnpmSymlinks: true, + }); + + try { + const report = JSON.parse(await readFile(fixture.auditReportPath, "utf8")) as { + copiedAudit: { externalSymlinks: string[]; resolvedModules: Record }; + }; + const copiedNextLink = join(fixture.destinationRoot, "apps", "web", "node_modules", "next"); + const nextTarget = await readlink(copiedNextLink); + + expect(path.isAbsolute(nextTarget)).toBe(false); + expect(report.copiedAudit.externalSymlinks).toEqual([]); + expect(report.copiedAudit.resolvedModules["next/package.json"].split(path.sep).join("/")).toMatch( + /open-design-web-standalone\/node_modules\/\.pnpm\/next@0\.0\.0\/node_modules\/next\/package\.json$/, + ); + } finally { + await rm(fixture.root, { force: true, recursive: true }); + } + }); }); diff --git a/tools/pack/tests/workspace-build.test.ts b/tools/pack/tests/workspace-build.test.ts index caccb426a..df02bf3d3 100644 --- a/tools/pack/tests/workspace-build.test.ts +++ b/tools/pack/tests/workspace-build.test.ts @@ -114,6 +114,10 @@ describe("ensureWorkspaceBuildArtifacts", () => { expect(builds).toBe(1); expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "hit"]); + expect(cache.report().entries.map((entry) => entry.nodeId)).toEqual([ + "win.workspace-build", + "win.workspace-build", + ]); expect(await readFile(join(root, "apps/packaged/dist/index.mjs"), "utf8")).toBe("build-1\n"); } finally { await rm(root, { force: true, recursive: true }); @@ -145,4 +149,45 @@ describe("ensureWorkspaceBuildArtifacts", () => { await rm(root, { force: true, recursive: true }); } }); + + it("keeps platform-specific workspace build cache nodes separate", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-workspace-build-platform-")); + const cache = new ToolPackCache(join(root, ".cache")); + const winConfig = createConfig(root, cache.root); + const macConfig: ToolPackConfig = { + ...winConfig, + platform: "mac", + roots: { + ...winConfig.roots, + output: { + ...winConfig.roots.output, + namespaceRoot: join(root, ".tmp", "out", "mac", "namespaces", "test"), + platformRoot: join(root, ".tmp", "out", "mac"), + }, + runtime: { + namespaceBaseRoot: join(root, ".tmp", "runtime", "mac", "namespaces"), + namespaceRoot: join(root, ".tmp", "runtime", "mac", "namespaces", "test"), + }, + }, + }; + + try { + await writeWorkspace(root); + await ensureWorkspaceBuildArtifacts(winConfig, cache, async () => { + await writeOutputs(root, "win-build"); + }); + await ensureWorkspaceBuildArtifacts(macConfig, cache, async () => { + await writeOutputs(root, "mac-build"); + }); + + expect(cache.report().entries.map((entry) => entry.nodeId)).toEqual([ + "win.workspace-build", + "mac.workspace-build", + ]); + expect(cache.report().entries.map((entry) => entry.status)).toEqual(["miss", "miss"]); + expect(await readFile(join(root, "apps/packaged/dist/index.mjs"), "utf8")).toBe("mac-build\n"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); });