Files
portfolio-hub/bootstrap/install.sh
T
genichin 0c7c1f7bcf Squashed '.vendor/stage-pilot/' changes from 646bc1e..e745b12
e745b12 feat: add Claude Code support via CLAUDE.md and .claude/commands/ wrapper files

git-subtree-dir: .vendor/stage-pilot
git-subtree-split: e745b12f579e8c17fc4c2421b157a1620e5dbc7d
2026-05-11 01:09:46 +00:00

371 lines
9.6 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: install.sh [--dry-run] [HOST_ROOT] [POLICY]
Options:
--dry-run Show planned changes without modifying files.
--source-path <path>
Source path for StagePilot assets: root/.github|base/.github
(default: root/.github)
--cleanup-legacy-base
Remove legacy base/.github directory under host root.
-h, --help Show this help message.
Arguments:
HOST_ROOT Target host repository root (default: current directory)
POLICY Conflict policy for non-copilot files: replace|preserve|fail
(default: STAGEPILOT_CONFLICT_POLICY or preserve)
EOF
}
log() {
printf '[install] %s\n' "$*"
}
fail() {
printf '[install] ERROR: %s\n' "$*" >&2
exit 1
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PACKAGE_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DRY_RUN=0
SOURCE_PATH="root/.github"
CLEANUP_LEGACY_BASE=0
ARGS=()
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
--source-path)
[ "$#" -ge 2 ] || fail "--source-path requires a value"
SOURCE_PATH="$2"
shift
;;
--cleanup-legacy-base)
CLEANUP_LEGACY_BASE=1
;;
-h|--help)
usage
exit 0
;;
--*)
fail "Unknown option: $1"
;;
*)
ARGS+=("$1")
;;
esac
shift
done
HOST_ROOT="${ARGS[0]:-$(pwd)}"
POLICY="${ARGS[1]:-${STAGEPILOT_CONFLICT_POLICY:-preserve}}"
TARGET_GITHUB="${HOST_ROOT}/.github"
TARGET_CLAUDE="${HOST_ROOT}/CLAUDE.md"
STAGEPILOT_BEGIN="<!-- STAGEPILOT:BEGIN -->"
STAGEPILOT_END="<!-- STAGEPILOT:END -->"
case "${SOURCE_PATH}" in
root/.github)
SOURCE_GITHUB="${PACKAGE_ROOT}/.github"
;;
base/.github)
SOURCE_GITHUB="${PACKAGE_ROOT}/base/.github"
;;
*)
fail "Invalid source path '${SOURCE_PATH}'. Allowed: root/.github|base/.github"
;;
esac
ALLOWLIST_FILE="${PACKAGE_ROOT}/bootstrap/source-allowlist.txt"
case "${POLICY}" in
replace|preserve|fail)
;;
*)
fail "Invalid conflict policy '${POLICY}'. Allowed: replace|preserve|fail"
;;
esac
[ -d "${SOURCE_GITHUB}" ] || fail "Source package not found: ${SOURCE_GITHUB}"
[ -d "${HOST_ROOT}" ] || fail "Host root not found: ${HOST_ROOT}"
[ -f "${ALLOWLIST_FILE}" ] || fail "Allowlist not found: ${ALLOWLIST_FILE}"
is_allowed_relpath() {
local rel_path="$1"
while IFS= read -r raw_line; do
line="${raw_line%%#*}"
line="${line%$'\r'}"
line="$(printf '%s' "${line}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[ -n "${line}" ] || continue
if [[ "${line}" == */ ]]; then
[[ "${rel_path}" == "${line}"* ]] && return 0
else
[[ "${rel_path}" == "${line}" ]] && return 0
fi
done < "${ALLOWLIST_FILE}"
return 1
}
merge_stagepilot_section() {
local src_file="$1"
local dst_file="$2"
local payload_file
local output_file
payload_file="$(mktemp)"
output_file="$(mktemp)"
# If source already has marker block, use only that block as payload.
if grep -Fq "${STAGEPILOT_BEGIN}" "${src_file}" && grep -Fq "${STAGEPILOT_END}" "${src_file}"; then
awk -v begin="${STAGEPILOT_BEGIN}" -v end="${STAGEPILOT_END}" '
$0 == begin { in_block=1; next }
$0 == end { in_block=0; exit }
in_block { print }
' "${src_file}" > "${payload_file}"
else
cat "${src_file}" > "${payload_file}"
fi
if grep -Fq "${STAGEPILOT_BEGIN}" "${dst_file}" && grep -Fq "${STAGEPILOT_END}" "${dst_file}"; then
awk -v begin="${STAGEPILOT_BEGIN}" -v end="${STAGEPILOT_END}" -v payload="${payload_file}" '
$0 == begin {
print
while ((getline line < payload) > 0) {
print line
}
in_block=1
next
}
$0 == end {
in_block=0
print
next
}
!in_block { print }
' "${dst_file}" > "${output_file}"
else
cat "${dst_file}" > "${output_file}"
{
printf '\n'
printf '## StagePilot\n'
printf '%s\n' "${STAGEPILOT_BEGIN}"
cat "${payload_file}"
printf '%s\n' "${STAGEPILOT_END}"
} >> "${output_file}"
fi
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] merge preview: ${dst_file}"
if command -v diff >/dev/null 2>&1; then
diff -u "${dst_file}" "${output_file}" || true
else
log "[dry-run] diff command not available; preview skipped for ${dst_file}"
fi
else
cp -a "${output_file}" "${dst_file}"
fi
rm -f "${payload_file}" "${output_file}"
}
install_claude_section() {
local src_copilot="${SOURCE_GITHUB}/copilot-instructions.md"
[ -f "${src_copilot}" ] || { log "copilot-instructions.md not found; skipping CLAUDE.md"; return 0; }
if [ ! -e "${TARGET_CLAUDE}" ]; then
local payload_file
payload_file="$(mktemp)"
if grep -Fq "${STAGEPILOT_BEGIN}" "${src_copilot}" && grep -Fq "${STAGEPILOT_END}" "${src_copilot}"; then
awk -v begin="${STAGEPILOT_BEGIN}" -v end="${STAGEPILOT_END}" '
$0 == begin { in_block=1; next }
$0 == end { in_block=0; exit }
in_block { print }
' "${src_copilot}" > "${payload_file}"
else
cat "${src_copilot}" > "${payload_file}"
fi
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] create: CLAUDE.md"
else
{
printf '%s\n' "${STAGEPILOT_BEGIN}"
cat "${payload_file}"
printf '%s\n' "${STAGEPILOT_END}"
} > "${TARGET_CLAUDE}"
fi
rm -f "${payload_file}"
else
merge_stagepilot_section "${src_copilot}" "${TARGET_CLAUDE}"
fi
}
# NOTE: skill을 추가/삭제/이름변경할 때는 .github/skills/<name>/SKILL.md와
# .claude/commands/<name>.md를 반드시 함께 처리한다. (README 12.1 참고)
install_claude_commands() {
local source_commands="${PACKAGE_ROOT}/.claude/commands"
local target_commands="${HOST_ROOT}/.claude/commands"
[ -d "${source_commands}" ] || { log "no .claude/commands source; skipping"; return 0; }
while IFS= read -r -d '' src_file; do
local cmd_name
cmd_name="$(basename "${src_file}")"
local dst_file="${target_commands}/${cmd_name}"
if [ ! -e "${dst_file}" ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] create: .claude/commands/${cmd_name}"
else
mkdir -p "${target_commands}"
cp -a "${src_file}" "${dst_file}"
fi
claude_installed=$((claude_installed + 1))
continue
fi
if cmp -s "${src_file}" "${dst_file}"; then
claude_skipped=$((claude_skipped + 1))
continue
fi
case "${POLICY}" in
replace)
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] replace: .claude/commands/${cmd_name}"
else
cp -a "${src_file}" "${dst_file}"
fi
claude_installed=$((claude_installed + 1))
;;
preserve)
claude_skipped=$((claude_skipped + 1))
;;
fail)
printf '[install] CONFLICT: .claude/commands/%s\n' "${cmd_name}" >&2
failed=$((failed + 1))
;;
esac
done < <(find "${source_commands}" -maxdepth 1 -name "*.md" -print0 | sort -z)
log "claude_commands: installed=${claude_installed} skipped=${claude_skipped}"
}
copied=0
skipped=0
conflicted=0
failed=0
merged=0
claude_installed=0
claude_skipped=0
mkdir -p "${TARGET_GITHUB}"
while IFS= read -r -d '' src_file; do
rel_path="${src_file#${SOURCE_GITHUB}/}"
if ! is_allowed_relpath "${rel_path}"; then
skipped=$((skipped + 1))
continue
fi
dst_file="${TARGET_GITHUB}/${rel_path}"
dst_dir="$(dirname "${dst_file}")"
mkdir -p "${dst_dir}"
if [ ! -e "${dst_file}" ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] copy: ${rel_path}"
else
cp -a "${src_file}" "${dst_file}"
fi
copied=$((copied + 1))
continue
fi
if cmp -s "${src_file}" "${dst_file}"; then
skipped=$((skipped + 1))
continue
fi
if [ "${rel_path}" = "copilot-instructions.md" ]; then
merge_stagepilot_section "${src_file}" "${dst_file}"
merged=$((merged + 1))
continue
fi
conflicted=$((conflicted + 1))
case "${POLICY}" in
replace)
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] replace: ${rel_path}"
else
cp -a "${src_file}" "${dst_file}"
fi
copied=$((copied + 1))
;;
preserve)
skipped=$((skipped + 1))
;;
fail)
printf '[install] CONFLICT: %s\n' "${dst_file}" >&2
failed=1
;;
esac
done < <(find "${SOURCE_GITHUB}" -type f -print0 | sort -z)
if [ "${CLEANUP_LEGACY_BASE}" -eq 1 ]; then
LEGACY_BASE_DIR="${HOST_ROOT}/base/.github"
if [ -d "${LEGACY_BASE_DIR}" ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] delete legacy path: ${LEGACY_BASE_DIR}"
else
rm -rf "${LEGACY_BASE_DIR}" || fail "LEGACY_BASE_DELETE_ERROR: failed to remove ${LEGACY_BASE_DIR}"
log "deleted legacy path: ${LEGACY_BASE_DIR}"
fi
else
log "legacy path not found, skip cleanup: ${LEGACY_BASE_DIR}"
fi
fi
install_claude_section
install_claude_commands
log "host_root=${HOST_ROOT}"
log "source=${SOURCE_GITHUB}"
log "source_path=${SOURCE_PATH}"
log "allowlist=${ALLOWLIST_FILE}"
log "cleanup_legacy_base=${CLEANUP_LEGACY_BASE}"
log "policy=${POLICY}"
log "copied=${copied} skipped=${skipped} conflicted=${conflicted} merged=${merged}"
if [ "${failed}" -ne 0 ]; then
if [ "${DRY_RUN}" -eq 1 ]; then
fail "[dry-run] Conflicts would occur with policy=fail."
fi
fail "Conflicts encountered with policy=fail. No bootstrap run."
fi
if [ "${DRY_RUN}" -eq 1 ]; then
log "[dry-run] Bootstrap step skipped. No files were changed."
exit 0
fi
BOOTSTRAP_SCRIPT="${PACKAGE_ROOT}/bootstrap/bootstrap.sh"
[ -f "${BOOTSTRAP_SCRIPT}" ] || fail "Bootstrap script not found: ${BOOTSTRAP_SCRIPT}"
/bin/bash "${BOOTSTRAP_SCRIPT}" "${HOST_ROOT}"
log "Install completed successfully."