diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 00000000..d4f3f7a3 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,44 @@ +{ + "name": "superpowers", + "version": "5.0.7", + "description": "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", + "author": { + "name": "Jesse Vincent", + "email": "jesse@fsck.com", + "url": "https://github.com/obra" + }, + "homepage": "https://github.com/obra/superpowers", + "repository": "https://github.com/obra/superpowers", + "license": "MIT", + "keywords": [ + "brainstorming", + "subagent-driven-development", + "skills", + "planning", + "tdd", + "debugging", + "code-review", + "workflow" + ], + "skills": "./skills/", + "interface": { + "displayName": "Superpowers", + "shortDescription": "Planning, TDD, debugging, and delivery workflows for coding agents", + "longDescription": "Use Superpowers to guide agent work through brainstorming, implementation planning, test-driven development, systematic debugging, parallel execution, code review, and finish-the-branch workflows.", + "developerName": "Jesse Vincent", + "category": "Coding", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "I've got an idea for something I'd like to build.", + "Let's add a feature to this project." + ], + "brandColor": "#F59E0B", + "composerIcon": "./assets/superpowers-small.svg", + "logo": "./assets/app-icon.png", + "screenshots": [] + } +} diff --git a/.version-bump.json b/.version-bump.json index f5dbe315..323acb3f 100644 --- a/.version-bump.json +++ b/.version-bump.json @@ -3,6 +3,7 @@ { "path": "package.json", "field": "version" }, { "path": ".claude-plugin/plugin.json", "field": "version" }, { "path": ".cursor-plugin/plugin.json", "field": "version" }, + { "path": ".codex-plugin/plugin.json", "field": "version" }, { "path": ".claude-plugin/marketplace.json", "field": "plugins.0.version" }, { "path": "gemini-extension.json", "field": "version" } ], diff --git a/assets/app-icon.png b/assets/app-icon.png new file mode 100644 index 00000000..25518da4 Binary files /dev/null and b/assets/app-icon.png differ diff --git a/assets/superpowers-small.svg b/assets/superpowers-small.svg new file mode 100644 index 00000000..c514fe33 --- /dev/null +++ b/assets/superpowers-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/sync-to-codex-plugin.sh b/scripts/sync-to-codex-plugin.sh index 0566170a..16fd89ae 100755 --- a/scripts/sync-to-codex-plugin.sh +++ b/scripts/sync-to-codex-plugin.sh @@ -3,9 +3,9 @@ # sync-to-codex-plugin.sh # # Sync this superpowers checkout → prime-radiant-inc/openai-codex-plugins. -# Clones the fork fresh into a temp dir, rsyncs upstream content, regenerates -# the Codex overlay file (.codex-plugin/plugin.json) inline, commits, pushes a -# sync branch, and opens a PR. +# Clones the fork fresh into a temp dir, rsyncs tracked upstream plugin content +# (including committed Codex files under .codex-plugin/ and assets/), commits, +# pushes a sync branch, and opens a PR. # Path/user agnostic — auto-detects upstream from script location. # # Deterministic: running twice against the same upstream SHA produces PRs with @@ -17,13 +17,11 @@ # ./scripts/sync-to-codex-plugin.sh -y # skip confirm # ./scripts/sync-to-codex-plugin.sh --local PATH # existing checkout # ./scripts/sync-to-codex-plugin.sh --base BRANCH # default: main -# ./scripts/sync-to-codex-plugin.sh --bootstrap --assets-src DIR # create initial plugin +# ./scripts/sync-to-codex-plugin.sh --bootstrap # create plugin dir if missing # -# Bootstrap mode: skips the "plugin must exist on base" check and seeds -# plugins/superpowers/assets/ from --assets-src which must contain -# PrimeRadiant_Favicon.svg and PrimeRadiant_Favicon.png. Run once by one -# team member to create the initial PR; every subsequent run is a normal -# (non-bootstrap) sync. +# Bootstrap mode: skips the "plugin must exist on base" requirement and creates +# plugins/superpowers/ when absent, then copies the tracked plugin files from +# upstream just like a normal sync. # # Requires: bash, rsync, git, gh (authenticated), python3. @@ -38,9 +36,6 @@ DEFAULT_BASE="main" DEST_REL="plugins/superpowers" # Paths in upstream that should NOT land in the embedded plugin. -# The Codex-only paths are here too — they're managed by generate/bootstrap -# steps, not by rsync. -# # All patterns use a leading "/" to anchor them to the source root. # Unanchored patterns like "scripts/" would match any directory named # "scripts" at any depth — including legitimate nested dirs like @@ -78,68 +73,57 @@ EXCLUDES=( "/scripts/" "/tests/" "/tmp/" - - # Codex-only paths — managed outside rsync - "/.codex-plugin/" - "/assets/" ) # ============================================================================= -# Generated overlay file +# Ignored-path helpers # ============================================================================= -# Writes the Codex plugin manifest to "$1" with the given upstream version. -# Args: dest_path, version -generate_plugin_json() { - local dest="$1" - local version="$2" - mkdir -p "$(dirname "$dest")" - cat > "$dest" <&2; usage 2 ;; esac @@ -187,19 +169,11 @@ command -v python3 >/dev/null || die "python3 not found in PATH" gh auth status >/dev/null 2>&1 || die "gh not authenticated — run 'gh auth login'" [[ -d "$UPSTREAM/.git" ]] || die "upstream '$UPSTREAM' is not a git checkout" -[[ -f "$UPSTREAM/package.json" ]] || die "upstream has no package.json — cannot read version" +[[ -f "$UPSTREAM/.codex-plugin/plugin.json" ]] || die "committed Codex manifest missing at $UPSTREAM/.codex-plugin/plugin.json" -# Bootstrap-mode validation -if [[ $BOOTSTRAP -eq 1 ]]; then - [[ -n "$ASSETS_SRC" ]] || die "--bootstrap requires --assets-src " - ASSETS_SRC="$(cd "$ASSETS_SRC" 2>/dev/null && pwd)" || die "assets source '$ASSETS_SRC' is not a directory" - [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.svg" ]] || die "assets source missing PrimeRadiant_Favicon.svg" - [[ -f "$ASSETS_SRC/PrimeRadiant_Favicon.png" ]] || die "assets source missing PrimeRadiant_Favicon.png" -fi - -# Read the upstream version from package.json -UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/package.json")" -[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from upstream package.json" +# Read the upstream version from the committed Codex manifest. +UPSTREAM_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$UPSTREAM/.codex-plugin/plugin.json")" +[[ -n "$UPSTREAM_VERSION" ]] || die "could not read 'version' from committed Codex manifest" UPSTREAM_BRANCH="$(cd "$UPSTREAM" && git branch --show-current)" UPSTREAM_SHA="$(cd "$UPSTREAM" && git rev-parse HEAD)" @@ -230,7 +204,9 @@ fi CLEANUP_DIR="" cleanup() { - [[ -n "$CLEANUP_DIR" ]] && rm -rf "$CLEANUP_DIR" + if [[ -n "$CLEANUP_DIR" ]]; then + rm -rf "$CLEANUP_DIR" + fi } trap cleanup EXIT @@ -245,22 +221,84 @@ else fi DEST="$DEST_REPO/$DEST_REL" +PREVIEW_REPO="$DEST_REPO" +PREVIEW_DEST="$DEST" -# Checkout base branch -cd "$DEST_REPO" -git checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK" +overlay_destination_paths() { + local repo="$1" + local path + local source_path + local preview_path -# Plugin-existence check depends on mode -if [[ $BOOTSTRAP -eq 1 ]]; then - [[ ! -d "$DEST" ]] || die "--bootstrap but base branch '$BASE' already has '$DEST_REL/' — use normal sync instead" - mkdir -p "$DEST" -else - [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap + --assets-src, or pass --base " -fi + while IFS= read -r -d '' path; do + source_path="$repo/$path" + preview_path="$PREVIEW_REPO/$path" -# ============================================================================= -# Create sync branch -# ============================================================================= + if [[ -e "$source_path" ]]; then + mkdir -p "$(dirname "$preview_path")" + cp -R "$source_path" "$preview_path" + else + rm -rf "$preview_path" + fi + done +} + +copy_local_destination_overlay() { + overlay_destination_paths "$DEST_REPO" < <( + git -C "$DEST_REPO" diff --name-only -z -- "$DEST_REL" + ) + overlay_destination_paths "$DEST_REPO" < <( + git -C "$DEST_REPO" diff --cached --name-only -z -- "$DEST_REL" + ) + overlay_destination_paths "$DEST_REPO" < <( + git -C "$DEST_REPO" ls-files --others --exclude-standard -z -- "$DEST_REL" + ) + overlay_destination_paths "$DEST_REPO" < <( + git -C "$DEST_REPO" ls-files --others --ignored --exclude-standard -z -- "$DEST_REL" + ) +} + +local_checkout_has_uncommitted_destination_changes() { + [[ -n "$(git -C "$DEST_REPO" status --porcelain=1 --untracked-files=all --ignored=matching -- "$DEST_REL")" ]] +} + +prepare_preview_checkout() { + if [[ -n "$LOCAL_CHECKOUT" ]]; then + [[ -n "$CLEANUP_DIR" ]] || CLEANUP_DIR="$(mktemp -d)" + PREVIEW_REPO="$CLEANUP_DIR/preview" + git clone -q --no-local "$DEST_REPO" "$PREVIEW_REPO" + PREVIEW_DEST="$PREVIEW_REPO/$DEST_REL" + fi + + git -C "$PREVIEW_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK" + if [[ -n "$LOCAL_CHECKOUT" ]]; then + copy_local_destination_overlay + fi + if [[ $BOOTSTRAP -ne 1 ]]; then + [[ -d "$PREVIEW_DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base " + fi +} + +prepare_apply_checkout() { + git -C "$DEST_REPO" checkout -q "$BASE" 2>/dev/null || die "base branch '$BASE' doesn't exist in $FORK" + if [[ $BOOTSTRAP -ne 1 ]]; then + [[ -d "$DEST" ]] || die "base branch '$BASE' has no '$DEST_REL/' — use --bootstrap, or pass --base " + fi +} + +apply_to_preview_checkout() { + if [[ $BOOTSTRAP -eq 1 ]]; then + mkdir -p "$PREVIEW_DEST" + fi + + rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$PREVIEW_DEST/" +} + +preview_checkout_has_changes() { + [[ -n "$(git -C "$PREVIEW_REPO" status --porcelain "$DEST_REL")" ]] +} + +prepare_preview_checkout TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)" if [[ $BOOTSTRAP -eq 1 ]]; then @@ -268,14 +306,15 @@ if [[ $BOOTSTRAP -eq 1 ]]; then else SYNC_BRANCH="sync/superpowers-${UPSTREAM_SHORT}-${TIMESTAMP}" fi -git checkout -q -b "$SYNC_BRANCH" # ============================================================================= # Build rsync args # ============================================================================= -RSYNC_ARGS=(-av --delete) +RSYNC_ARGS=(-av --delete --delete-excluded) for pat in "${EXCLUDES[@]}"; do RSYNC_ARGS+=(--exclude="$pat"); done +append_git_ignored_directory_excludes +append_git_ignored_file_excludes # ============================================================================= # Dry run preview (always shown) @@ -288,20 +327,13 @@ echo "Fork: $FORK" echo "Base: $BASE" echo "Branch: $SYNC_BRANCH" if [[ $BOOTSTRAP -eq 1 ]]; then - echo "Mode: BOOTSTRAP (creating initial plugin from scratch)" - echo "Assets: $ASSETS_SRC" + echo "Mode: BOOTSTRAP (creating plugins/superpowers/ when absent)" fi echo "" echo "=== Preview (rsync --dry-run) ===" -rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$DEST/" +rsync "${RSYNC_ARGS[@]}" --dry-run --itemize-changes "$UPSTREAM/" "$PREVIEW_DEST/" echo "=== End preview ===" echo "" -echo "Overlay file (.codex-plugin/plugin.json) will be regenerated with" -echo "version $UPSTREAM_VERSION regardless of rsync output." -if [[ $BOOTSTRAP -eq 1 ]]; then - echo "Assets (superpowers-small.svg, app-icon.png) will be seeded from:" - echo " $ASSETS_SRC" -fi if [[ $DRY_RUN -eq 1 ]]; then echo "" @@ -317,18 +349,26 @@ echo "" confirm "Apply changes, push branch, and open PR?" || { echo "Aborted."; exit 1; } echo "" -echo "Syncing upstream content..." -rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/" +if [[ -n "$LOCAL_CHECKOUT" ]]; then + if local_checkout_has_uncommitted_destination_changes; then + die "local checkout has uncommitted changes under '$DEST_REL' — commit, stash, or discard them before syncing" + fi -if [[ $BOOTSTRAP -eq 1 ]]; then - echo "Seeding brand assets..." - mkdir -p "$DEST/assets" - cp "$ASSETS_SRC/PrimeRadiant_Favicon.svg" "$DEST/assets/superpowers-small.svg" - cp "$ASSETS_SRC/PrimeRadiant_Favicon.png" "$DEST/assets/app-icon.png" + apply_to_preview_checkout + if ! preview_checkout_has_changes; then + echo "No changes — embedded plugin was already in sync with upstream $UPSTREAM_SHORT (v$UPSTREAM_VERSION)." + exit 0 + fi fi -echo "Regenerating overlay file..." -generate_plugin_json "$DEST/.codex-plugin/plugin.json" "$UPSTREAM_VERSION" +prepare_apply_checkout +cd "$DEST_REPO" +git checkout -q -b "$SYNC_BRANCH" +echo "Syncing upstream content..." +if [[ $BOOTSTRAP -eq 1 ]]; then + mkdir -p "$DEST" +fi +rsync "${RSYNC_ARGS[@]}" "$UPSTREAM/" "$DEST/" # Bail early if nothing actually changed cd "$DEST_REPO" @@ -347,16 +387,18 @@ if [[ $BOOTSTRAP -eq 1 ]]; then COMMIT_TITLE="bootstrap superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" PR_BODY="Initial bootstrap of the superpowers plugin from upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). -Creates \`plugins/superpowers/\` from scratch: upstream content via rsync, \`.codex-plugin/plugin.json\` regenerated inline, brand assets seeded from a local Brand Assets directory. +Creates \`plugins/superpowers/\` by copying the tracked plugin files from upstream, including \`.codex-plugin/plugin.json\` and \`assets/\`. -Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap --assets-src \` +Run via: \`scripts/sync-to-codex-plugin.sh --bootstrap\` Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA -This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs and will not touch the \`assets/\` directory." +This is a one-time bootstrap. Subsequent syncs will be normal (non-bootstrap) runs using the same tracked upstream plugin files." else COMMIT_TITLE="sync superpowers v$UPSTREAM_VERSION from upstream main @ $UPSTREAM_SHORT" PR_BODY="Automated sync from superpowers upstream \`main\` @ \`$UPSTREAM_SHORT\` (v$UPSTREAM_VERSION). +Copies the tracked plugin files from upstream, including the committed Codex manifest and assets. + Run via: \`scripts/sync-to-codex-plugin.sh\` Upstream commit: https://github.com/obra/superpowers/commit/$UPSTREAM_SHA diff --git a/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh new file mode 100755 index 00000000..a8fc245b --- /dev/null +++ b/tests/codex-plugin-sync/test-sync-to-codex-plugin.sh @@ -0,0 +1,571 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +SYNC_SCRIPT_SOURCE="$REPO_ROOT/scripts/sync-to-codex-plugin.sh" +BASH_UNDER_TEST="/bin/bash" +PACKAGE_VERSION="1.2.3" +MANIFEST_VERSION="9.8.7" + +FAILURES=0 +TEST_ROOT="" + +pass() { + echo " [PASS] $1" +} + +fail() { + echo " [FAIL] $1" + FAILURES=$((FAILURES + 1)) +} + +assert_equals() { + local actual="$1" + local expected="$2" + local description="$3" + + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description" + echo " expected: $expected" + echo " actual: $actual" + fi +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Fq -- "$needle"; then + pass "$description" + else + fail "$description" + echo " expected to find: $needle" + fi +} + +assert_not_contains() { + local haystack="$1" + local needle="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Fq -- "$needle"; then + fail "$description" + echo " did not expect to find: $needle" + else + pass "$description" + fi +} + +assert_matches() { + local haystack="$1" + local pattern="$2" + local description="$3" + + if printf '%s' "$haystack" | grep -Eq -- "$pattern"; then + pass "$description" + else + fail "$description" + echo " expected to match: $pattern" + fi +} + +assert_path_absent() { + local path="$1" + local description="$2" + + if [[ ! -e "$path" ]]; then + pass "$description" + else + fail "$description" + echo " did not expect path to exist: $path" + fi +} + +assert_branch_absent() { + local repo="$1" + local pattern="$2" + local description="$3" + local branches + + branches="$(git -C "$repo" branch --list "$pattern")" + + if [[ -z "$branches" ]]; then + pass "$description" + else + fail "$description" + echo " did not expect matching branches:" + echo "$branches" | sed 's/^/ /' + fi +} + +assert_current_branch() { + local repo="$1" + local expected="$2" + local description="$3" + local actual + + actual="$(git -C "$repo" branch --show-current)" + assert_equals "$actual" "$expected" "$description" +} + +assert_file_equals() { + local path="$1" + local expected="$2" + local description="$3" + local actual + + actual="$(cat "$path")" + assert_equals "$actual" "$expected" "$description" +} + +cleanup() { + if [[ -n "$TEST_ROOT" && -d "$TEST_ROOT" ]]; then + rm -rf "$TEST_ROOT" + fi +} + +configure_git_identity() { + local repo="$1" + + git -C "$repo" config user.name "Test Bot" + git -C "$repo" config user.email "test@example.com" +} + +init_repo() { + local repo="$1" + + git init -q -b main "$repo" + configure_git_identity "$repo" +} + +commit_fixture() { + local repo="$1" + local message="$2" + + git -C "$repo" commit -q -m "$message" +} + +checkout_fixture_branch() { + local repo="$1" + local branch="$2" + + git -C "$repo" checkout -q -b "$branch" +} + +write_upstream_fixture() { + local repo="$1" + local with_pure_ignored="${2:-1}" + + mkdir -p \ + "$repo/.codex-plugin" \ + "$repo/.private-journal" \ + "$repo/assets" \ + "$repo/scripts" \ + "$repo/skills/example" + + if [[ "$with_pure_ignored" == "1" ]]; then + mkdir -p "$repo/ignored-cache/tmp" + fi + + cp "$SYNC_SCRIPT_SOURCE" "$repo/scripts/sync-to-codex-plugin.sh" + + cat > "$repo/package.json" < "$repo/.gitignore" <<'EOF' +.private-journal/ +EOF + + if [[ "$with_pure_ignored" == "1" ]]; then + cat >> "$repo/.gitignore" <<'EOF' +ignored-cache/ +EOF + fi + + cat > "$repo/.codex-plugin/plugin.json" < "$repo/assets/superpowers-small.svg" <<'EOF' + +EOF + + printf 'png fixture\n' > "$repo/assets/app-icon.png" + + cat > "$repo/skills/example/SKILL.md" <<'EOF' +# Example Skill + +Fixture content. +EOF + + printf 'tracked keep\n' > "$repo/.private-journal/keep.txt" + printf 'ignored leak\n' > "$repo/.private-journal/leak.txt" + if [[ "$with_pure_ignored" == "1" ]]; then + printf 'ignored cache state\n' > "$repo/ignored-cache/tmp/state.json" + fi + + git -C "$repo" add \ + .codex-plugin/plugin.json \ + .gitignore \ + assets/app-icon.png \ + assets/superpowers-small.svg \ + package.json \ + scripts/sync-to-codex-plugin.sh \ + skills/example/SKILL.md + git -C "$repo" add -f .private-journal/keep.txt + + commit_fixture "$repo" "Initial upstream fixture" +} + +write_destination_fixture() { + local repo="$1" + + mkdir -p "$repo/plugins/superpowers/skills/example" + printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep" + cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF' +# Example Skill + +Fixture content. +EOF + git -C "$repo" add plugins/superpowers/.fixture-keep + git -C "$repo" add plugins/superpowers/skills/example/SKILL.md + + commit_fixture "$repo" "Initial destination fixture" +} + +dirty_tracked_destination_skill() { + local repo="$1" + + cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF' +# Example Skill + +Locally modified fixture content. +EOF +} + +write_synced_destination_fixture() { + local repo="$1" + + mkdir -p \ + "$repo/plugins/superpowers/.codex-plugin" \ + "$repo/plugins/superpowers/.private-journal" \ + "$repo/plugins/superpowers/assets" \ + "$repo/plugins/superpowers/skills/example" + + cat > "$repo/plugins/superpowers/.codex-plugin/plugin.json" < "$repo/plugins/superpowers/assets/superpowers-small.svg" <<'EOF' + +EOF + + printf 'png fixture\n' > "$repo/plugins/superpowers/assets/app-icon.png" + + cat > "$repo/plugins/superpowers/skills/example/SKILL.md" <<'EOF' +# Example Skill + +Fixture content. +EOF + + printf 'tracked keep\n' > "$repo/plugins/superpowers/.private-journal/keep.txt" + + git -C "$repo" add \ + plugins/superpowers/.codex-plugin/plugin.json \ + plugins/superpowers/assets/app-icon.png \ + plugins/superpowers/assets/superpowers-small.svg \ + plugins/superpowers/skills/example/SKILL.md \ + plugins/superpowers/.private-journal/keep.txt + + commit_fixture "$repo" "Initial synced destination fixture" +} + +write_stale_ignored_destination_fixture() { + local repo="$1" + + mkdir -p "$repo/plugins/superpowers/.private-journal" + printf 'fixture keep\n' > "$repo/plugins/superpowers/.fixture-keep" + printf 'stale ignored leak\n' > "$repo/plugins/superpowers/.private-journal/leak.txt" + git -C "$repo" add plugins/superpowers/.fixture-keep + + commit_fixture "$repo" "Initial stale ignored destination fixture" +} + +write_fake_gh() { + local bin_dir="$1" + + mkdir -p "$bin_dir" + + cat > "$bin_dir/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "auth" && "${2:-}" == "status" ]]; then + exit 0 +fi + +echo "unexpected gh invocation: $*" >&2 +exit 1 +EOF + + chmod +x "$bin_dir/gh" +} + +run_preview() { + local upstream="$1" + local dest="$2" + local fake_bin="$3" + + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1 +} + +run_bootstrap_preview() { + local upstream="$1" + local dest="$2" + local fake_bin="$3" + + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --bootstrap --local "$dest" 2>&1 +} + +run_preview_without_manifest() { + local upstream="$1" + local dest="$2" + local fake_bin="$3" + + rm -f "$upstream/.codex-plugin/plugin.json" + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1 +} + +run_preview_with_stale_ignored_destination() { + local upstream="$1" + local dest="$2" + local fake_bin="$3" + + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -n --local "$dest" 2>&1 +} + +run_apply() { + local upstream="$1" + local dest="$2" + local fake_bin="$3" + + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" -y --local "$dest" 2>&1 +} + +run_help() { + local upstream="$1" + local fake_bin="$2" + + PATH="$fake_bin:$PATH" "$BASH_UNDER_TEST" "$upstream/scripts/sync-to-codex-plugin.sh" --help 2>&1 +} + +write_bootstrap_destination_fixture() { + local repo="$1" + + printf 'bootstrap fixture\n' > "$repo/README.md" + git -C "$repo" add README.md + + commit_fixture "$repo" "Initial bootstrap destination fixture" +} + +main() { + local upstream + local mixed_only_upstream + local dest + local dest_branch + local mixed_only_dest + local stale_dest + local dirty_apply_dest + local dirty_apply_dest_branch + local noop_apply_dest + local noop_apply_dest_branch + local fake_bin + local bootstrap_dest + local bootstrap_dest_branch + local preview_status + local preview_output + local preview_section + local bootstrap_status + local bootstrap_output + local missing_manifest_status + local missing_manifest_output + local mixed_only_status + local mixed_only_output + local stale_preview_status + local stale_preview_output + local stale_preview_section + local dirty_apply_status + local dirty_apply_output + local noop_apply_status + local noop_apply_output + local help_output + local script_source + local dirty_skill_path + + echo "=== Test: sync-to-codex-plugin dry-run regression ===" + + TEST_ROOT="$(mktemp -d)" + trap cleanup EXIT + + upstream="$TEST_ROOT/upstream" + mixed_only_upstream="$TEST_ROOT/mixed-only-upstream" + dest="$TEST_ROOT/destination" + mixed_only_dest="$TEST_ROOT/mixed-only-destination" + stale_dest="$TEST_ROOT/stale-destination" + dirty_apply_dest="$TEST_ROOT/dirty-apply-destination" + dirty_apply_dest_branch="fixture/dirty-apply-target" + noop_apply_dest="$TEST_ROOT/noop-apply-destination" + noop_apply_dest_branch="fixture/noop-apply-target" + bootstrap_dest="$TEST_ROOT/bootstrap-destination" + dest_branch="fixture/preview-target" + bootstrap_dest_branch="fixture/bootstrap-preview-target" + fake_bin="$TEST_ROOT/bin" + + init_repo "$upstream" + write_upstream_fixture "$upstream" + + init_repo "$mixed_only_upstream" + write_upstream_fixture "$mixed_only_upstream" 0 + + init_repo "$dest" + write_destination_fixture "$dest" + checkout_fixture_branch "$dest" "$dest_branch" + dirty_tracked_destination_skill "$dest" + + init_repo "$mixed_only_dest" + write_destination_fixture "$mixed_only_dest" + + init_repo "$stale_dest" + write_stale_ignored_destination_fixture "$stale_dest" + + init_repo "$dirty_apply_dest" + write_synced_destination_fixture "$dirty_apply_dest" + checkout_fixture_branch "$dirty_apply_dest" "$dirty_apply_dest_branch" + dirty_tracked_destination_skill "$dirty_apply_dest" + + init_repo "$noop_apply_dest" + write_synced_destination_fixture "$noop_apply_dest" + checkout_fixture_branch "$noop_apply_dest" "$noop_apply_dest_branch" + + init_repo "$bootstrap_dest" + write_bootstrap_destination_fixture "$bootstrap_dest" + checkout_fixture_branch "$bootstrap_dest" "$bootstrap_dest_branch" + + write_fake_gh "$fake_bin" + + # This regression test is about dry-run content, so capture the preview + # output even if the current script exits nonzero in --local mode. + set +e + preview_output="$(run_preview "$upstream" "$dest" "$fake_bin")" + preview_status=$? + bootstrap_output="$(run_bootstrap_preview "$upstream" "$bootstrap_dest" "$fake_bin")" + bootstrap_status=$? + mixed_only_output="$(run_preview "$mixed_only_upstream" "$mixed_only_dest" "$fake_bin")" + mixed_only_status=$? + stale_preview_output="$(run_preview_with_stale_ignored_destination "$upstream" "$stale_dest" "$fake_bin")" + stale_preview_status=$? + dirty_apply_output="$(run_apply "$upstream" "$dirty_apply_dest" "$fake_bin")" + dirty_apply_status=$? + noop_apply_output="$(run_apply "$upstream" "$noop_apply_dest" "$fake_bin")" + noop_apply_status=$? + missing_manifest_output="$(run_preview_without_manifest "$upstream" "$dest" "$fake_bin")" + missing_manifest_status=$? + set -e + help_output="$(run_help "$upstream" "$fake_bin")" + script_source="$(cat "$upstream/scripts/sync-to-codex-plugin.sh")" + preview_section="$(printf '%s\n' "$preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')" + stale_preview_section="$(printf '%s\n' "$stale_preview_output" | sed -n '/^=== Preview (rsync --dry-run) ===$/,/^=== End preview ===$/p')" + dirty_skill_path="$dirty_apply_dest/plugins/superpowers/skills/example/SKILL.md" + + echo "" + echo "Preview assertions..." + assert_equals "$preview_status" "0" "Preview exits successfully" + assert_contains "$preview_output" "Version: $MANIFEST_VERSION" "Preview uses manifest version" + assert_not_contains "$preview_output" "Version: $PACKAGE_VERSION" "Preview does not use package.json version" + assert_contains "$preview_section" ".codex-plugin/plugin.json" "Preview includes manifest path" + assert_contains "$preview_section" "assets/superpowers-small.svg" "Preview includes SVG asset" + assert_contains "$preview_section" "assets/app-icon.png" "Preview includes PNG asset" + assert_contains "$preview_section" ".private-journal/keep.txt" "Preview includes tracked ignored file" + assert_not_contains "$preview_section" ".private-journal/leak.txt" "Preview excludes ignored untracked file" + assert_not_contains "$preview_section" "ignored-cache/" "Preview excludes pure ignored directories" + assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note" + assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note" + assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file" + assert_current_branch "$dest" "$dest_branch" "Preview leaves destination checkout on its original branch" + assert_branch_absent "$dest" "sync/superpowers-*" "Preview does not create sync branch in destination checkout" + + echo "" + echo "Mixed-directory assertions..." + assert_equals "$mixed_only_status" "0" "Mixed ignored directory preview exits successfully under /bin/bash" + assert_contains "$mixed_only_output" ".private-journal/keep.txt" "Mixed ignored directory preview still includes tracked ignored file" + assert_not_contains "$mixed_only_output" "ignored-cache/" "Mixed ignored directory preview has no pure ignored directory fixture" + + echo "" + echo "Convergence assertions..." + assert_equals "$stale_preview_status" "0" "Stale ignored destination preview exits successfully" + assert_matches "$stale_preview_section" "\\*deleting +\\.private-journal/leak\\.txt" "Preview deletes stale ignored destination file" + + echo "" + echo "Bootstrap assertions..." + assert_equals "$bootstrap_status" "0" "Bootstrap preview exits successfully" + assert_contains "$bootstrap_output" "Mode: BOOTSTRAP (creating plugins/superpowers/ when absent)" "Bootstrap preview describes directory creation" + assert_not_contains "$bootstrap_output" "Assets:" "Bootstrap preview omits external assets path" + assert_contains "$bootstrap_output" "Dry run only. Nothing was changed or pushed." "Bootstrap preview remains dry-run only" + assert_path_absent "$bootstrap_dest/plugins/superpowers" "Bootstrap preview does not create destination plugin directory" + assert_current_branch "$bootstrap_dest" "$bootstrap_dest_branch" "Bootstrap preview leaves destination checkout on its original branch" + assert_branch_absent "$bootstrap_dest" "bootstrap/superpowers-*" "Bootstrap preview does not create bootstrap branch in destination checkout" + + echo "" + echo "Apply assertions..." + assert_equals "$dirty_apply_status" "1" "Dirty local apply exits with failure" + assert_contains "$dirty_apply_output" "ERROR: local checkout has uncommitted changes under 'plugins/superpowers'" "Dirty local apply reports protected destination path" + assert_current_branch "$dirty_apply_dest" "$dirty_apply_dest_branch" "Dirty local apply leaves destination checkout on its original branch" + assert_branch_absent "$dirty_apply_dest" "sync/superpowers-*" "Dirty local apply does not create sync branch in destination checkout" + assert_file_equals "$dirty_skill_path" "# Example Skill + +Locally modified fixture content." "Dirty local apply preserves tracked working-tree file content" + assert_equals "$noop_apply_status" "0" "Clean no-op local apply exits successfully" + assert_contains "$noop_apply_output" "No changes — embedded plugin was already in sync with upstream" "Clean no-op local apply reports no changes" + assert_current_branch "$noop_apply_dest" "$noop_apply_dest_branch" "Clean no-op local apply leaves destination checkout on its original branch" + assert_branch_absent "$noop_apply_dest" "sync/superpowers-*" "Clean no-op local apply does not create sync branch in destination checkout" + + echo "" + echo "Missing manifest assertions..." + assert_equals "$missing_manifest_status" "1" "Missing manifest exits with failure" + assert_contains "$missing_manifest_output" "ERROR: committed Codex manifest missing at" "Missing manifest reports committed manifest path" + + echo "" + echo "Help assertions..." + assert_not_contains "$help_output" "--assets-src" "Help omits --assets-src" + + echo "" + echo "Source assertions..." + assert_not_contains "$script_source" "regenerated inline" "Source drops regenerated inline phrasing" + assert_not_contains "$script_source" "Brand Assets directory" "Source drops Brand Assets directory phrasing" + assert_not_contains "$script_source" "--assets-src" "Source drops --assets-src" + + if [[ $FAILURES -ne 0 ]]; then + echo "" + echo "FAILED: $FAILURES assertion(s) failed." + exit 1 + fi + + echo "" + echo "PASS" +} + +main "$@"