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:
PerishFire 2026-05-07 19:13:52 +08:00 committed by GitHub
parent 5abca505b1
commit cb92c93ae0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2961 additions and 1751 deletions

View file

@ -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"

View file

@ -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

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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) を参照。

View file

@ -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`를 실행하세요.
첫 번째 로드 시:

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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)。

View file

@ -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)。

View file

@ -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) {

View file

@ -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', () => {

View file

@ -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,
};

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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",

View file

@ -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);
}

View file

@ -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', () => {

View file

@ -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",

View file

@ -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[] = [];

View file

@ -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);

View file

@ -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);
}
}
}

View file

@ -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
```

View file

@ -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}`,
);

View file

@ -11,7 +11,7 @@ import {
startPackedMacApp,
stopPackedMacApp,
uninstallPackedMacApp,
} from "./mac.js";
} from "./mac/index.js";
import {
cleanupPackedWinNamespace,
installPackedWinApp,

View file

@ -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;

View 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

View 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
View 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);
}

View 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 };
}

View 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,
};
}

View 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 }),
},
});
}

View 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]);
}

View 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
View 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.
}
}

View 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";

View 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,
};
}

View 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;
}

View 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");
}

View 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
View 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">;

View 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);
});
}

View file

@ -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,

View 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");
});
});

View file

@ -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 {

View file

@ -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 });
}
});
});

View file

@ -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 });
}
});
});