mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Migrate beta release publishing to R2 (#805)
* Prebundle standalone web packaged runtime * Harden mac standalone prebundle policy * Prebundle mac daemon packaged runtime * Prune mac Electron locales * Maximize mac release artifact compression * Publish beta mac artifacts to R2 * Use remote R2 uploads for beta releases * Fail fast on beta R2 access issues * Use S3-compatible uploads for beta R2 releases * Decouple beta versioning from GitHub releases * Remove legacy beta metadata source * Address release beta review notes
This commit is contained in:
parent
5abca505b1
commit
cb92c93ae0
52 changed files with 2961 additions and 1751 deletions
610
.github/workflows/release-beta.yml
vendored
610
.github/workflows/release-beta.yml
vendored
|
|
@ -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" <<EOF
|
||||
version: "${{ needs.metadata.outputs.beta_version }}"
|
||||
|
|
@ -196,8 +380,6 @@ jobs:
|
|||
needs: metadata
|
||||
if: ${{ inputs.enable_win }}
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
|
@ -440,7 +622,12 @@ jobs:
|
|||
|
||||
- name: Prepare windows beta assets
|
||||
shell: pwsh
|
||||
env:
|
||||
CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN: ${{ secrets.CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN)) {
|
||||
throw "CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN is required"
|
||||
}
|
||||
$releaseDir = Join-Path $env:RUNNER_TEMP "release-assets"
|
||||
New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null
|
||||
|
||||
|
|
@ -460,7 +647,9 @@ jobs:
|
|||
$installerBytes = [System.IO.File]::ReadAllBytes($installerPath)
|
||||
$installerSha512 = [System.Convert]::ToBase64String([System.Security.Cryptography.SHA512]::Create().ComputeHash($installerBytes))
|
||||
$installerSize = (Get-Item $installerPath).Length
|
||||
$installerUrl = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/${{ needs.metadata.outputs.version_tag }}/$versionedInstaller"
|
||||
$publicOrigin = $env:CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN.TrimEnd("/")
|
||||
$versionPrefix = "beta/versions/${{ needs.metadata.outputs.beta_version }}${{ needs.metadata.outputs.asset_version_suffix }}"
|
||||
$installerUrl = "$publicOrigin/$versionPrefix/$versionedInstaller"
|
||||
$releaseDate = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
@(
|
||||
'version: "${{ needs.metadata.outputs.beta_version }}"'
|
||||
|
|
@ -485,8 +674,6 @@ jobs:
|
|||
needs: metadata
|
||||
if: ${{ inputs.enable_linux }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
|
@ -559,7 +746,7 @@ jobs:
|
|||
path: ${{ runner.temp }}/release-assets
|
||||
|
||||
publish:
|
||||
name: Publish beta release
|
||||
name: Publish beta release to R2
|
||||
needs:
|
||||
- metadata
|
||||
- build_mac
|
||||
|
|
@ -577,16 +764,23 @@ jobs:
|
|||
}}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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 }}
|
||||
ASSET_VERSION_SUFFIX: ${{ needs.metadata.outputs.asset_version_suffix }}
|
||||
BASE_VERSION: ${{ needs.metadata.outputs.base_version }}
|
||||
BETA_VERSION: ${{ needs.metadata.outputs.beta_version }}
|
||||
BRANCH_NAME: ${{ needs.metadata.outputs.branch }}
|
||||
ENABLE_LINUX: ${{ inputs.enable_linux }}
|
||||
ENABLE_MAC: ${{ inputs.enable_mac }}
|
||||
ENABLE_WIN: ${{ inputs.enable_win }}
|
||||
ENABLE_LINUX: ${{ inputs.enable_linux }}
|
||||
RELEASE_SIGNED: ${{ needs.metadata.outputs.signed }}
|
||||
STATE_SOURCE: ${{ needs.metadata.outputs.state_source }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download mac release bundle
|
||||
if: ${{ inputs.enable_mac }}
|
||||
uses: actions/download-artifact@v8
|
||||
|
|
@ -608,129 +802,242 @@ jobs:
|
|||
name: open-design-beta-linux-release-assets
|
||||
path: ${{ runner.temp }}/release-assets/linux
|
||||
|
||||
- name: Move beta tags to current commit
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Publish beta assets and metadata to R2
|
||||
id: r2
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git tag -f "${{ needs.metadata.outputs.version_tag }}" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/${{ needs.metadata.outputs.version_tag }}" --force
|
||||
git tag -f "${{ needs.metadata.outputs.beta_tag }}" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/${{ needs.metadata.outputs.beta_tag }}" --force
|
||||
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
|
||||
|
||||
- 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" <<EOF
|
||||
## Summary
|
||||
- channel: beta
|
||||
- version: ${{ needs.metadata.outputs.beta_version }}
|
||||
- base version: ${{ needs.metadata.outputs.base_version }}
|
||||
- mac enabled: ${{ inputs.enable_mac }}
|
||||
- mac signed/notarized: ${{ needs.metadata.outputs.signed }}
|
||||
- windows enabled: ${{ inputs.enable_win }}
|
||||
- windows signed: false
|
||||
- linux enabled: ${{ inputs.enable_linux }}
|
||||
- branch: ${{ needs.metadata.outputs.branch }}
|
||||
- commit: ${{ needs.metadata.outputs.commit }}
|
||||
release_root="$RUNNER_TEMP/release-assets"
|
||||
public_origin="${CLOUDFLARE_R2_RELEASES_PUBLIC_ORIGIN%/}"
|
||||
version_prefix="beta/versions/${BETA_VERSION}${ASSET_VERSION_SUFFIX}"
|
||||
latest_prefix="beta/latest"
|
||||
|
||||
This beta release ships the enabled platform artifacts, checksums, and updater feed files for enabled auto-update platforms. Linux AppImage has no auto-update feed yet.
|
||||
EOF
|
||||
cat > "$latest_notes_file" <<EOF
|
||||
## Summary
|
||||
- channel: beta
|
||||
- latest version: ${{ needs.metadata.outputs.beta_version }}
|
||||
- latest tag: ${{ needs.metadata.outputs.version_tag }}
|
||||
upload() {
|
||||
local file_path="$1"
|
||||
local object_key="$2"
|
||||
local content_type="$3"
|
||||
local cache_control="$4"
|
||||
if [ ! -f "$file_path" ]; then
|
||||
echo "expected upload file not found: $file_path" >&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"
|
||||
|
|
|
|||
2
.github/workflows/release-stable.yml
vendored
2
.github/workflows/release-stable.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) を参照。
|
||||
|
|
|
|||
|
|
@ -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`를 실행하세요.
|
||||
|
||||
첫 번째 로드 시:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -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)。
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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<PackagedConfig> {
|
||||
const raw = await readRawPackagedConfig();
|
||||
const namespace = normalizeNamespace(
|
||||
|
|
@ -124,13 +140,19 @@ export async function readPackagedConfig(): Promise<PackagedConfig> {
|
|||
? 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,7 +80,10 @@ async function main(): Promise<void> {
|
|||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<StandaloneBackend> {
|
||||
async function startStandaloneBackend(webRoot: string | null): Promise<StandaloneBackend> {
|
||||
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<SidecarStamp>,
|
||||
webRoot: string,
|
||||
webRoot: string | null,
|
||||
): Promise<WebSidecarHandle> {
|
||||
const daemonOrigin = resolveDaemonOrigin();
|
||||
const backend = await startStandaloneBackend(webRoot);
|
||||
|
|
@ -653,10 +660,11 @@ async function startStandaloneNextSidecar(
|
|||
}
|
||||
|
||||
export async function startWebSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<WebSidecarHandle> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -338,11 +338,60 @@ async function checkWebTestLayout(): Promise<boolean> {
|
|||
return true;
|
||||
}
|
||||
|
||||
const toolsRootAllowlist = new Map<string, "directory" | "file">([
|
||||
// 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<boolean> {
|
||||
const toolsRoot = path.join(repoRoot, "tools");
|
||||
const entries = await readdir(toolsRoot, { withFileTypes: true });
|
||||
const seen = new Set<string>();
|
||||
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[] = [];
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
|
||||
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<string> {
|
||||
|
|
@ -131,25 +149,83 @@ async function readPackagedVersion(): Promise<string> {
|
|||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function fetchReleases(repository: string): Promise<GitHubRelease[]> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
startPackedMacApp,
|
||||
stopPackedMacApp,
|
||||
uninstallPackedMacApp,
|
||||
} from "./mac.js";
|
||||
} from "./mac/index.js";
|
||||
import {
|
||||
cleanupPackedWinNamespace,
|
||||
installPackedWinApp,
|
||||
|
|
|
|||
|
|
@ -891,7 +891,7 @@ export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxS
|
|||
// launches as `unmanaged`, which on uninstall would also remove the
|
||||
// AppImage/desktop/icon files out from under the still-running app.
|
||||
// Accept either TOOLS_PACK (CLI start) or PACKAGED (menu launch). Mirrors
|
||||
// the dual-source acceptance pattern in mac.ts:709-714.
|
||||
// the dual-source acceptance pattern in mac/lifecycle.ts.
|
||||
const expectedIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
|
|
@ -932,7 +932,7 @@ export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxS
|
|||
};
|
||||
}
|
||||
|
||||
// Try graceful shutdown via IPC first. mac.ts's pattern: best-effort SHUTDOWN
|
||||
// Try graceful shutdown via IPC first. mac/lifecycle.ts's pattern: best-effort SHUTDOWN
|
||||
// request with a short timeout so Electron renderers + sidecars get a chance
|
||||
// to flush state (SQLite WAL, logs) before SIGTERM.
|
||||
let gracefulRequested = false;
|
||||
|
|
|
|||
130
tools/pack/src/mac-prebundle.ts
Normal file
130
tools/pack/src/mac-prebundle.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { ToolPackConfig } from "./config.js";
|
||||
|
||||
export const MAC_PREBUNDLED_APP_DIR_NAME = "prebundled";
|
||||
export const MAC_PREBUNDLE_META_DIR_NAME = "prebundle-meta";
|
||||
export const MAC_PREBUNDLED_PACKAGED_MAIN_RELATIVE_PATH = "app/prebundled/packaged-main.mjs";
|
||||
export const MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH = "app/prebundled/web-sidecar.mjs";
|
||||
export const MAC_PREBUNDLED_DAEMON_CLI_RELATIVE_PATH = "app/prebundled/daemon/daemon-cli.mjs";
|
||||
export const MAC_PREBUNDLED_DAEMON_SIDECAR_RELATIVE_PATH = "app/prebundled/daemon/daemon-sidecar.mjs";
|
||||
export const MAC_PREBUNDLE_ESBUILD_TARGET = "node24";
|
||||
export const MAC_DAEMON_PREBUNDLE_ESM_REQUIRE_BANNER =
|
||||
'import { createRequire as __odCreateRequire } from "node:module"; const require = __odCreateRequire(import.meta.url);';
|
||||
export const MAC_PREBUNDLE_ENTRYPOINTS_DIR_NAME = "prebundle-entrypoints";
|
||||
|
||||
export const MAC_PREBUNDLE_RUNTIME_DEPENDENCIES = {
|
||||
"better-sqlite3": "12.9.0",
|
||||
} as const;
|
||||
|
||||
export const MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES = [
|
||||
"@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",
|
||||
] as const;
|
||||
|
||||
export const MAC_PREBUNDLE_POLICIES = {
|
||||
packagedMain: {
|
||||
externals: ["electron"],
|
||||
forbiddenInputs: [
|
||||
"/apps/web/",
|
||||
"/node_modules/@open-design/web/",
|
||||
"/node_modules/next/",
|
||||
"/node_modules/openai/",
|
||||
"/node_modules/react/",
|
||||
"/node_modules/react-dom/",
|
||||
],
|
||||
label: "packaged main",
|
||||
},
|
||||
daemonCli: {
|
||||
externals: ["better-sqlite3"],
|
||||
forbiddenInputs: [
|
||||
"/node_modules/@open-design/daemon/",
|
||||
"/node_modules/better-sqlite3/",
|
||||
"/node_modules/electron/",
|
||||
"/node_modules/next/",
|
||||
"/node_modules/openai/",
|
||||
"/node_modules/react/",
|
||||
"/node_modules/react-dom/",
|
||||
],
|
||||
label: "daemon cli",
|
||||
},
|
||||
daemonSidecar: {
|
||||
externals: ["better-sqlite3"],
|
||||
forbiddenInputs: [
|
||||
"/node_modules/@open-design/daemon/",
|
||||
"/node_modules/better-sqlite3/",
|
||||
"/node_modules/electron/",
|
||||
"/node_modules/next/",
|
||||
"/node_modules/openai/",
|
||||
"/node_modules/react/",
|
||||
"/node_modules/react-dom/",
|
||||
],
|
||||
label: "daemon sidecar",
|
||||
},
|
||||
webSidecar: {
|
||||
externals: [],
|
||||
forbiddenInputs: [
|
||||
"/node_modules/next/",
|
||||
"/node_modules/openai/",
|
||||
"/node_modules/react/",
|
||||
"/node_modules/react-dom/",
|
||||
],
|
||||
label: "web sidecar",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MacPrebundlePolicyName = keyof typeof MAC_PREBUNDLE_POLICIES;
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export function shouldUseMacStandalonePrebundle(webOutputMode: ToolPackConfig["webOutputMode"]): boolean {
|
||||
return webOutputMode === "standalone";
|
||||
}
|
||||
|
||||
export function shouldInstallInternalPackageForMacPrebundle(options: {
|
||||
packageName: string;
|
||||
webOutputMode: ToolPackConfig["webOutputMode"];
|
||||
}): boolean {
|
||||
if (!shouldUseMacStandalonePrebundle(options.webOutputMode)) return true;
|
||||
return !MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES.includes(
|
||||
options.packageName as (typeof MAC_STANDALONE_PREBUNDLE_EXCLUDED_INTERNAL_PACKAGES)[number],
|
||||
);
|
||||
}
|
||||
|
||||
export function findForbiddenMacPrebundleInputs(options: {
|
||||
forbiddenInputs: readonly string[];
|
||||
inputs: readonly string[];
|
||||
}): string[] {
|
||||
return options.inputs
|
||||
.map(toPosixPath)
|
||||
.filter((input) => options.forbiddenInputs.some((forbidden) => input.includes(forbidden)));
|
||||
}
|
||||
|
||||
export async function assertMacPrebundleMetafile(options: {
|
||||
metafilePath: string;
|
||||
policyName: MacPrebundlePolicyName;
|
||||
}): Promise<void> {
|
||||
const policy = MAC_PREBUNDLE_POLICIES[options.policyName];
|
||||
const metafile = JSON.parse(await readFile(options.metafilePath, "utf8")) as { inputs?: Record<string, unknown> };
|
||||
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';
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
46
tools/pack/src/mac/app-config.ts
Normal file
46
tools/pack/src/mac/app-config.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
246
tools/pack/src/mac/app.ts
Normal file
246
tools/pack/src/mac/app.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<PackedTarballInfo[]> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
94
tools/pack/src/mac/artifacts.ts
Normal file
94
tools/pack/src/mac/artifacts.ts
Normal file
|
|
@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Pick<MacPackResult, "dmgPath" | "latestMacYmlPath" | "zipPath">> {
|
||||
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 };
|
||||
}
|
||||
62
tools/pack/src/mac/build.ts
Normal file
62
tools/pack/src/mac/build.ts
Normal file
|
|
@ -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<MacPackResult> {
|
||||
const paths = resolveMacPaths(config);
|
||||
const targets = resolveElectronBuilderTargets(config.to as MacBuildOutput);
|
||||
const cache = new ToolPackCache(config.roots.cacheRoot);
|
||||
const timings: MacPackTiming[] = [];
|
||||
const runPhase = async <T>(phase: string, task: () => Promise<T>): Promise<T> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
160
tools/pack/src/mac/builder.ts
Normal file
160
tools/pack/src/mac/builder.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
31
tools/pack/src/mac/commands.ts
Normal file
31
tools/pack/src/mac/commands.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], {
|
||||
cwd: appRoot,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runEsbuild(config: ToolPackConfig, args: string[]): Promise<void> {
|
||||
await runPnpm(config, ["--filter", "@open-design/packaged", "exec", "esbuild", ...args]);
|
||||
}
|
||||
52
tools/pack/src/mac/constants.ts
Normal file
52
tools/pack/src/mac/constants.ts
Normal file
|
|
@ -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;
|
||||
|
||||
70
tools/pack/src/mac/fs.ts
Normal file
70
tools/pack/src/mac/fs.ts
Normal file
|
|
@ -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<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sizePathBytes(
|
||||
path: string,
|
||||
options: { includeFile?: (path: string) => boolean } = {},
|
||||
): Promise<number> {
|
||||
let metadata: Awaited<ReturnType<typeof lstat>>;
|
||||
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<number | null> {
|
||||
try {
|
||||
const metadata = await stat(path);
|
||||
return metadata.size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sumChildDirectorySizes(path: string, includeChild: (name: string) => boolean): Promise<number> {
|
||||
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<void> {
|
||||
try {
|
||||
await execFileAsync("xattr", ["-dr", "com.apple.quarantine", path]);
|
||||
} catch {
|
||||
// Ignore when the attribute is absent or unsupported for local unsigned artifacts.
|
||||
}
|
||||
}
|
||||
23
tools/pack/src/mac/index.ts
Normal file
23
tools/pack/src/mac/index.ts
Normal file
|
|
@ -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";
|
||||
489
tools/pack/src/mac/lifecycle.ts
Normal file
489
tools/pack/src/mac/lifecycle.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||
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<DesktopRootIdentityMarker> | 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<DesktopStatusSnapshot | null> {
|
||||
const stamp = desktopStamp(config);
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
return await requestJsonIpc<DesktopStatusSnapshot>(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<boolean> {
|
||||
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<MacInstallResult> {
|
||||
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<MacStartResult> {
|
||||
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<MacStopResult> {
|
||||
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<MacInspectResult> {
|
||||
const stamp = desktopStamp(config);
|
||||
const status = await requestJsonIpc<DesktopStatusSnapshot>(
|
||||
stamp.ipc,
|
||||
{ type: SIDECAR_MESSAGES.STATUS },
|
||||
{ timeoutMs: 2000 },
|
||||
).catch(() => null);
|
||||
|
||||
return {
|
||||
...(options.expr == null ? {} : {
|
||||
eval: await requestJsonIpc<DesktopEvalResult>(
|
||||
stamp.ipc,
|
||||
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
|
||||
{ timeoutMs: 5000 },
|
||||
),
|
||||
}),
|
||||
...(options.path == null ? {} : {
|
||||
screenshot: await requestJsonIpc<DesktopScreenshotResult>(
|
||||
stamp.ipc,
|
||||
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
|
||||
{ timeoutMs: 10000 },
|
||||
),
|
||||
}),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uninstallPackedMacApp(config: ToolPackConfig): Promise<MacUninstallResult> {
|
||||
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<MacCleanupResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
13
tools/pack/src/mac/manifest.ts
Normal file
13
tools/pack/src/mac/manifest.ts
Normal file
|
|
@ -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<string> {
|
||||
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;
|
||||
}
|
||||
88
tools/pack/src/mac/paths.ts
Normal file
88
tools/pack/src/mac/paths.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
85
tools/pack/src/mac/report.ts
Normal file
85
tools/pack/src/mac/report.ts
Normal file
|
|
@ -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<MacPackResult, "dmgPath" | "zipPath">,
|
||||
targets: ElectronBuilderTarget[],
|
||||
): Promise<MacSizeReport> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
183
tools/pack/src/mac/types.ts
Normal file
183
tools/pack/src/mac/types.ts
Normal file
|
|
@ -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<DesktopRootIdentityMarker>;
|
||||
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<ToolPackBuildOutput, "all" | "app" | "dmg" | "zip">;
|
||||
38
tools/pack/src/mac/workspace.ts
Normal file
38
tools/pack/src/mac/workspace.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
await ensureWorkspaceBuildArtifacts(config, cache, async () => {
|
||||
await buildWorkspaceArtifacts(config);
|
||||
});
|
||||
}
|
||||
|
|
@ -64,10 +64,11 @@ async function createWorkspaceBuildCacheKey(config: ToolPackConfig): Promise<str
|
|||
for (const packageInfo of WORKSPACE_BUILD_PACKAGES) {
|
||||
packageHashes[packageInfo.name] = await hashPackageSourcePath(join(config.workspaceRoot, packageInfo.directory));
|
||||
}
|
||||
const nodeId = `${config.platform}.workspace-build`;
|
||||
|
||||
return hashJson({
|
||||
buildCommands: BUILD_COMMANDS,
|
||||
node: "win.workspace-build",
|
||||
node: nodeId,
|
||||
nodeVersion: process.version,
|
||||
packageHashes,
|
||||
packageManager: await readPackageManager(config.workspaceRoot),
|
||||
|
|
@ -155,6 +156,7 @@ export async function ensureWorkspaceBuildArtifacts(
|
|||
build: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const key = await createWorkspaceBuildCacheKey(config);
|
||||
const nodeId = `${config.platform}.workspace-build`;
|
||||
const artifacts = workspaceBuildArtifacts(config);
|
||||
await cache.acquire<WorkspaceBuildMetadata>({
|
||||
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,
|
||||
|
|
|
|||
145
tools/pack/tests/mac-prebundle.test.ts
Normal file
145
tools/pack/tests/mac-prebundle.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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> = {}): ToolPackConfig {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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<v
|
|||
await writeFile(join(packageRoot, "index.js"), "module.exports = {};\n", "utf8");
|
||||
}
|
||||
|
||||
async function writePnpmLinkedPackage(standaloneRoot: string, packageName: string): Promise<string> {
|
||||
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<void> {
|
||||
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<void> {
|
|||
|
||||
async function writeStandaloneFixture(
|
||||
workspaceRoot: string,
|
||||
options: { includeHoistedNext: boolean; includeWebNext: boolean },
|
||||
options: { includeHoistedNext: boolean; includeWebNext: boolean; useAbsolutePnpmSymlinks?: boolean },
|
||||
): Promise<string> {
|
||||
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<string, string> };
|
||||
};
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue