#!/usr/bin/env bash set -euo pipefail usage() { cat <<'USAGE' Usage: build-local-plugins.sh --source-dir Plugins --output-dir build/LocalPlugins [--plugin Demo]... build-local-plugins.sh --source-dir Plugins --output-dir build/PluginRelease/Build --configuration Release --skip-catalog Conventions: - A plugin can be an existing .mactoolsplugin directory. - Or a plugin source directory can contain plugin.json and the declared bundle. - If the declared bundle is missing and the directory has project.yml or *.xcodeproj, the script tries to build it with xcodebuild first. - --plugin accepts a plugin directory name or plugin ID. It can be repeated or passed as a comma-separated list. - Set --sign-identity or PLUGIN_CODE_SIGN_IDENTITY to sign packaged bundles. - Set --unsigned-build when a later packaging step will sign the bundle. - Set --xcodebuild or XCODEBUILD to override the xcodebuild executable. Generated output: build/LocalPlugins/ Packages/*.mactoolsplugin catalog.dev.json unless --skip-catalog is set USAGE } SOURCE_DIR="" OUTPUT_DIR="" PLUGIN_FILTERS=() CONFIGURATION="${CONFIGURATION:-Debug}" DESTINATION="${DESTINATION:-}" XCODEBUILD_COMMAND="${XCODEBUILD:-}" SIGN_IDENTITY="${PLUGIN_CODE_SIGN_IDENTITY:-}" SKIP_CATALOG=0 UNSIGNED_BUILD=0 while [[ $# -gt 0 ]]; do case "$1" in --source-dir) SOURCE_DIR="${2:-}" shift 2 ;; --output-dir) OUTPUT_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 ;; --configuration) CONFIGURATION="${2:-}" shift 2 ;; --destination) DESTINATION="${2:-}" shift 2 ;; --xcodebuild) XCODEBUILD_COMMAND="${2:-}" shift 2 ;; --sign-identity) SIGN_IDENTITY="${2:-}" shift 2 ;; --skip-catalog) SKIP_CATALOG=1 shift ;; --unsigned-build) UNSIGNED_BUILD=1 shift ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 1 ;; esac done if [[ -z "$SOURCE_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. Expected: $SOURCE_DIR" >&2 exit 1 fi REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" if [[ -z "$XCODEBUILD_COMMAND" ]]; then XCODEBUILD_COMMAND="$REPO_ROOT/scripts/xcodebuild-filtered.sh" fi OUTPUT_DIR="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)" PACKAGES_DIR="$OUTPUT_DIR/Packages" DERIVED_DATA_DIR="$OUTPUT_DIR/DerivedData" CATALOG_PATH="$OUTPUT_DIR/catalog.dev.json" rm -rf "$PACKAGES_DIR" mkdir -p "$PACKAGES_DIR" "$DERIVED_DATA_DIR" json_value() { local file="$1" local expression="$2" python3 - "$file" "$expression" <<'PY' import json import sys path, expression = sys.argv[1:] data = json.load(open(path)) value = data for part in expression.split("."): if not part: continue if isinstance(value, dict): value = value.get(part) else: value = None break if value is None: print("") else: print(value) PY } discover_candidates() { if [[ -d "$SOURCE_DIR" && "$SOURCE_DIR" == *.mactoolsplugin ]]; then printf '%s\n' "$SOURCE_DIR" return fi if [[ -f "$SOURCE_DIR/plugin.json" ]]; then printf '%s\n' "$SOURCE_DIR" return fi find "$SOURCE_DIR" -maxdepth 3 \( -name plugin.json -o -name '*.mactoolsplugin' \) -print \ | while IFS= read -r path; do if [[ "$path" == *.mactoolsplugin ]]; then printf '%s\n' "$path" else dirname "$path" fi done \ | sort -u } matches_filter() { local candidate="$1" if [[ ${#PLUGIN_FILTERS[@]} -eq 0 ]]; then return 0 fi local basename basename="$(basename "$candidate")" basename="${basename%.mactoolsplugin}" local id="" if [[ -f "$candidate/plugin.json" ]]; then id="$(json_value "$candidate/plugin.json" "id")" fi local filter for filter in "${PLUGIN_FILTERS[@]}"; do [[ "$basename" == "$filter" ]] && return 0 [[ -n "$id" && "$id" == "$filter" ]] && return 0 done return 1 } project_file_for() { local root="$1" local manifest="$2" local output_var="$3" local configured_project configured_project="$(json_value "$manifest" "build.project")" [[ -z "$configured_project" ]] && configured_project="$(json_value "$manifest" "buildProject")" if [[ -n "$configured_project" ]]; then local project_path if [[ "$configured_project" = /* ]]; then project_path="$configured_project" else project_path="$(cd "$root" && cd "$(dirname "$configured_project")" && printf '%s/%s\n' "$(pwd)" "$(basename "$configured_project")")" fi printf -v "$output_var" '%s' "$project_path" return fi if [[ -f "$root/project.yml" ]]; then if ! (cd "$root" && xcodegen generate >/dev/null); then echo "Failed to generate Xcode project for $root." >&2 return 1 fi fi local project_path project_path="$(find "$root" -maxdepth 1 -name '*.xcodeproj' -print | sort | head -1)" printf -v "$output_var" '%s' "$project_path" } scheme_for() { local root="$1" local project="$2" local manifest="$3" local output_var="$4" local candidate_scheme candidate_scheme="$(json_value "$manifest" "build.scheme")" [[ -n "$candidate_scheme" ]] && { printf -v "$output_var" '%s' "$candidate_scheme"; return; } candidate_scheme="$(json_value "$manifest" "buildScheme")" [[ -n "$candidate_scheme" ]] && { printf -v "$output_var" '%s' "$candidate_scheme"; return; } local root_name root_name="$(basename "$root")" if "$XCODEBUILD_COMMAND" -list -project "$project" 2>/dev/null | grep -q "^[[:space:]]*$root_name$"; then printf -v "$output_var" '%s' "$root_name" return fi candidate_scheme="$("$XCODEBUILD_COMMAND" -list -json -project "$project" 2>/dev/null \ | python3 -c 'import json,sys; data=json.load(sys.stdin); print((data.get("project") or {}).get("schemes", [""])[0])')" printf -v "$output_var" '%s' "$candidate_scheme" } build_bundle_if_needed() { local root="$1" local manifest="$2" local bundle_relative_path="$3" local bundle_name="$4" local output_var="$5" if [[ -d "$root/$bundle_relative_path" ]]; then printf -v "$output_var" '%s' "$root/$bundle_relative_path" return fi local project project_file_for "$root" "$manifest" project if [[ -z "$project" ]]; then echo "Bundle not found and no Xcode project exists: $root/$bundle_relative_path" >&2 return 1 fi local scheme scheme_for "$root" "$project" "$manifest" scheme if [[ -z "$scheme" ]]; then echo "Unable to infer xcodebuild scheme for $root" >&2 return 1 fi local derived_data derived_data="$DERIVED_DATA_DIR/$(basename "$root")" rm -rf "$derived_data" local build_args=( -project "$project" -scheme "$scheme" -configuration "$CONFIGURATION" -derivedDataPath "$derived_data" ) if [[ -n "$DESTINATION" ]]; then build_args+=(-destination "$DESTINATION") fi if [[ "$UNSIGNED_BUILD" == "1" ]]; then build_args+=( CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= ) fi build_args+=(build -quiet) if ! "$XCODEBUILD_COMMAND" "${build_args[@]}"; then echo "Failed to build plugin bundle '$scheme' for $root." >&2 return 1 fi local built_bundle built_bundle="$(find "$derived_data/Build/Products" -name "$bundle_name" -type d -print | sort | head -1)" if [[ -z "$built_bundle" ]]; then echo "Built bundle not found: $bundle_name" >&2 return 1 fi printf -v "$output_var" '%s' "$built_bundle" } package_source_dir() { local root="$1" local output_var="$2" local manifest="$root/plugin.json" if [[ ! -f "$manifest" ]]; then echo "Missing plugin.json: $root" >&2 return 1 fi local plugin_id local bundle_relative_path local bundle_name plugin_id="$(json_value "$manifest" "id")" bundle_relative_path="$(json_value "$manifest" "bundleRelativePath")" bundle_name="$(basename "$bundle_relative_path")" if [[ -z "$plugin_id" || -z "$bundle_relative_path" ]]; then echo "plugin.json must include id and bundleRelativePath: $manifest" >&2 return 1 fi local bundle_path build_bundle_if_needed "$root" "$manifest" "$bundle_relative_path" "$bundle_name" bundle_path local package_path="$PACKAGES_DIR/$plugin_id.mactoolsplugin" 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" if [[ -n "$SIGN_IDENTITY" ]]; then "$REPO_ROOT/scripts/plugins/sign-plugin-package.sh" \ --package "$package_path" \ --identity "$SIGN_IDENTITY" fi [[ -d "$package_path" ]] || { echo "Failed to create plugin package: $package_path" >&2; return 1; } printf -v "$output_var" '%s' "$package_path" } copy_package_dir() { local package="$1" local output_var="$2" local package_name package_name="$(basename "$package")" local destination="$PACKAGES_DIR/$package_name" rm -rf "$destination" "$REPO_ROOT/scripts/plugins/build-plugin-package.sh" \ --source "$package" \ --output-dir "$PACKAGES_DIR" >/dev/null printf -v "$output_var" '%s' "$destination" } packages=() while IFS= read -r candidate; do [[ -n "$candidate" ]] || continue matches_filter "$candidate" || continue if [[ "$candidate" == *.mactoolsplugin ]]; then built_package_path="" copy_package_dir "$candidate" built_package_path packages+=("$built_package_path") else built_package_path="" package_source_dir "$candidate" built_package_path packages+=("$built_package_path") fi done < <(discover_candidates) if [[ ${#packages[@]} -eq 0 ]]; then if [[ ${#PLUGIN_FILTERS[@]} -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 if [[ "$SKIP_CATALOG" != "1" ]]; then catalog_args=() for package in "${packages[@]}"; do catalog_args+=(--package "$package") done "$REPO_ROOT/scripts/plugins/generate-plugin-catalog.sh" \ --mode debug \ --output "$CATALOG_PATH" \ "${catalog_args[@]}" fi echo "Built ${#packages[@]} plugin package(s)." if [[ "$SKIP_CATALOG" != "1" ]]; then echo "Catalog: $CATALOG_PATH" fi