#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: sync-debug-plugins.sh --source-dir Plugins --products-dir build/DerivedData/Build/Products/Debug --output-dir build/LocalPlugins Synchronizes Debug plugin bundles already built by the main MacTools scheme into development .mactoolsplugin packages, generates a local debug catalog, and copies the packages into the MacTools Dev installed plugin store. This script does not run xcodebuild. Run it after the Debug app build. USAGE } SOURCE_DIR="" PRODUCTS_DIR="" OUTPUT_DIR="" INSTALL_DIR="$HOME/Library/Application Support/MacTools Dev/Plugins/Installed" SKIP_INSTALL=0 PLUGIN_FILTERS=() while [[ $# -gt 0 ]]; do case "$1" in --source-dir) SOURCE_DIR="${2:-}" shift 2 ;; --products-dir) PRODUCTS_DIR="${2:-}" shift 2 ;; --output-dir) OUTPUT_DIR="${2:-}" shift 2 ;; --install-dir) INSTALL_DIR="${2:-}" shift 2 ;; --plugin) IFS=',' read -r -a raw_plugin_filters <<< "${2:-}" for raw_plugin_filter in "${raw_plugin_filters[@]}"; do raw_plugin_filter="${raw_plugin_filter#"${raw_plugin_filter%%[![:space:]]*}"}" raw_plugin_filter="${raw_plugin_filter%"${raw_plugin_filter##*[![:space:]]}"}" [[ -n "$raw_plugin_filter" ]] && PLUGIN_FILTERS+=("$raw_plugin_filter") done shift 2 ;; --skip-install) SKIP_INSTALL=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac done if [[ -z "$SOURCE_DIR" || -z "$PRODUCTS_DIR" || -z "$OUTPUT_DIR" ]]; then usage >&2 exit 1 fi SOURCE_DIR="$(cd "$SOURCE_DIR" 2>/dev/null && pwd || true)" if [[ -z "$SOURCE_DIR" || ! -d "$SOURCE_DIR" ]]; then echo "Plugin source directory not found: $SOURCE_DIR" >&2 exit 1 fi PRODUCTS_DIR="$(cd "$PRODUCTS_DIR" 2>/dev/null && pwd || true)" if [[ -z "$PRODUCTS_DIR" || ! -d "$PRODUCTS_DIR" ]]; then echo "Debug build products directory not found. Run 'make build' first: $PRODUCTS_DIR" >&2 exit 1 fi REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" OUTPUT_DIR="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)" PACKAGES_DIR="$OUTPUT_DIR/Packages" CATALOG_PATH="$OUTPUT_DIR/catalog.dev.json" STATE_DIR="$OUTPUT_DIR/.sync-state" mkdir -p "$PACKAGES_DIR" mkdir -p "$STATE_DIR" if [[ "$SKIP_INSTALL" != "1" ]]; then mkdir -p "$INSTALL_DIR" fi copy_package_to_installed_store() { local package_path="$1" local plugin_id="$2" local destination="$INSTALL_DIR/$plugin_id.mactoolsplugin" local staging="$INSTALL_DIR/.$plugin_id.syncing.$$.mactoolsplugin" rm -rf "$staging" ditto "$package_path" "$staging" rm -rf "$destination" mv "$staging" "$destination" } discover_plugin_records() { local plugin_filters_serialized="" for plugin_filter in "${PLUGIN_FILTERS[@]-}"; do plugin_filters_serialized+=$'\n'"$plugin_filter" done python3 - "$SOURCE_DIR" "$PRODUCTS_DIR" "$plugin_filters_serialized" <<'PY' import hashlib import json import os import pathlib import sys source_dir = pathlib.Path(sys.argv[1]) products_dir = pathlib.Path(sys.argv[2]) serialized_filters = sys.argv[3] if len(sys.argv) > 3 else "" filters = {item for item in serialized_filters.splitlines() if item} def emit_error(message: str) -> None: print(message, file=sys.stderr) raise SystemExit(1) def validate_field(value: str) -> str: if "\t" in value or "\n" in value: emit_error(f"Unsupported tab or newline in plugin sync field: {value!r}") return value def discover_candidates() -> list[pathlib.Path]: if (source_dir / "plugin.json").is_file(): return [source_dir] candidates = [] for root, dirs, files in os.walk(source_dir): root_path = pathlib.Path(root) depth = len(root_path.relative_to(source_dir).parts) if depth >= 3: dirs[:] = [] if "plugin.json" in files: candidates.append(root_path) return sorted(set(candidates), key=lambda path: path.name.lower()) def input_fingerprint(manifest: pathlib.Path, bundle: pathlib.Path) -> str: digest = hashlib.sha256() def is_ignored(path: pathlib.Path) -> bool: relative_parts = path.relative_to(bundle).parts return "_CodeSignature" in relative_parts def update_file(label: str, path: pathlib.Path) -> None: stat = path.lstat() digest.update(label.encode("utf-8")) digest.update(b"\0file\0") digest.update(oct(stat.st_mode & 0o7777).encode("utf-8")) digest.update(b"\0") with path.open("rb") as file: for chunk in iter(lambda: file.read(1024 * 1024), b""): digest.update(chunk) digest.update(b"\0") def update_symlink(label: str, path: pathlib.Path) -> None: stat = path.lstat() digest.update(label.encode("utf-8")) digest.update(b"\0symlink\0") digest.update(oct(stat.st_mode & 0o7777).encode("utf-8")) digest.update(b"\0") digest.update(os.readlink(path).encode("utf-8")) digest.update(b"\0") update_file("plugin.json", manifest) for path in sorted(bundle.rglob("*"), key=lambda item: item.relative_to(bundle).as_posix()): if is_ignored(path): continue relative = path.relative_to(bundle).as_posix() if path.is_symlink(): update_symlink(relative, path) elif path.is_file(): update_file(relative, path) return digest.hexdigest() records = [] for plugin_root in discover_candidates(): manifest = plugin_root / "plugin.json" with manifest.open("r", encoding="utf-8") as file: data = json.load(file) plugin_id = data.get("id") or "" bundle_relative_path = data.get("bundleRelativePath") or "" if not plugin_id or not bundle_relative_path: emit_error(f"plugin.json must include id and bundleRelativePath: {manifest}") if filters and plugin_root.name not in filters and plugin_id not in filters: continue bundle_name = pathlib.Path(bundle_relative_path).name bundle_path = products_dir / bundle_name if not bundle_path.is_dir(): emit_error( f"Built plugin bundle not found for {plugin_id}: {bundle_path}\n" "Run 'make build' and ensure the plugin target is included in the MacTools scheme." ) records.append(( str(plugin_root), str(manifest), plugin_id, bundle_relative_path, bundle_name, str(bundle_path), input_fingerprint(manifest, bundle_path), )) if not records: if filters: emit_error(f"No plugin matched requested filters in {source_dir}: {' '.join(sorted(filters))}") emit_error(f"No plugins found in {source_dir}.") for record in records: print("\t".join(validate_field(field) for field in record)) PY } state_file_for_plugin() { local plugin_id="$1" local safe_name safe_name="$(printf '%s' "$plugin_id" | tr -c 'A-Za-z0-9._-' '_')" printf '%s/%s.sha256\n' "$STATE_DIR" "$safe_name" } package_is_complete() { local package_path="$1" local bundle_relative_path="$2" [[ -f "$package_path/plugin.json" && -d "$package_path/$bundle_relative_path" ]] } packages=() synced_count=0 installed_count=0 skipped_count=0 while IFS=$'\t' read -r plugin_root manifest plugin_id bundle_relative_path bundle_name bundle_path fingerprint; do [[ -n "$plugin_root" ]] || continue package_path="$PACKAGES_DIR/$plugin_id.mactoolsplugin" state_path="$(state_file_for_plugin "$plugin_id")" previous_fingerprint="" package_synced=0 if [[ -f "$state_path" ]]; then previous_fingerprint="$(<"$state_path")" fi if [[ "$fingerprint" != "$previous_fingerprint" ]] || ! package_is_complete "$package_path" "$bundle_relative_path"; then rm -rf "$package_path" mkdir -p "$package_path/$(dirname "$bundle_relative_path")" ditto "$manifest" "$package_path/plugin.json" ditto "$bundle_path" "$package_path/$bundle_relative_path" printf '%s\n' "$fingerprint" > "$state_path" synced_count=$((synced_count + 1)) package_synced=1 else skipped_count=$((skipped_count + 1)) fi if [[ "$SKIP_INSTALL" != "1" ]]; then install_path="$INSTALL_DIR/$plugin_id.mactoolsplugin" if [[ "$package_synced" == "1" || ! -d "$install_path" ]]; then copy_package_to_installed_store "$package_path" "$plugin_id" installed_count=$((installed_count + 1)) fi fi packages+=("$package_path") done < <(discover_plugin_records) plugin_filter_count=0 for _plugin_filter in "${PLUGIN_FILTERS[@]-}"; do plugin_filter_count=$((plugin_filter_count + 1)) done if [[ ${#packages[@]} -eq 0 ]]; then if [[ "$plugin_filter_count" -gt 0 ]]; then echo "No plugin matched requested filters in $SOURCE_DIR: ${PLUGIN_FILTERS[*]-}" >&2 else echo "No plugins found in $SOURCE_DIR." >&2 fi exit 1 fi catalog_args=() for package in "${packages[@]}"; do catalog_args+=(--package "$package") done if [[ "$synced_count" -gt 0 || ! -f "$CATALOG_PATH" ]]; then "$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \ --mode debug \ --output "$CATALOG_PATH" \ "${catalog_args[@]}" fi echo "Synced $synced_count changed debug plugin package(s); skipped $skipped_count unchanged." echo "Catalog: $CATALOG_PATH" if [[ "$SKIP_INSTALL" != "1" ]]; then echo "Installed $installed_count debug plugin package(s)." echo "Installed store: $INSTALL_DIR" fi