Single-commit clean baseline after security scrub of niche-tells, project codenames, internal jargon, and contributor-email leaks. Contents: - 100 Rust crates (_primitives/_rust/) - 37 agent manifests (_manifests/) + generated specs (_generated/) - 67 user-invocable skills (skills/) - 33 hooks (hooks/) - Composition blocks (_blocks/) - Documentation (docs/, README.md) - TS adapter packages (_ts_packages/) - Assembler (_assembler/) - Roles (_roles/) - Templates (_templates/) - Forgejo CI (.forgejo/) Author: Denis Parfionovich <info@greendragon.info> License: see LICENSE.
597 lines
22 KiB
Bash
597 lines
22 KiB
Bash
#!/usr/bin/env bash
|
|
# kei-drive-import — interactive Google Drive → local Forgejo importer.
|
|
# Generated from _templates/drive-import-wizard.sh.tmpl by install/lib-dev-hub-gdrive-import.sh.
|
|
# Frozen flag block + 5-step pre-push checklist per tasks/kei-gdrive-import/PLAN.md (Wave 2 R1/R3).
|
|
|
|
set -u
|
|
set -o pipefail
|
|
IFS=$'\n\t'
|
|
|
|
KIT_DIR="${KIT_DIR:-${HOME}/Projects/KeiSeiKit}"
|
|
SECRETS_FILE="${HOME}/.claude/secrets/.env"
|
|
# Source secrets EARLY so KEI_FORGEJO_USER + KEI_FORGEJO_URL flow into the
|
|
# defaults below. Step 0 re-validates and re-sources defensively.
|
|
if [ -f "$SECRETS_FILE" ]; then
|
|
set -a; . "$SECRETS_FILE"; set +a
|
|
fi
|
|
FORGEJO_URL="${KEI_FORGEJO_URL:-http://127.0.0.1:3001}"
|
|
FORGEJO_URL="${FORGEJO_URL%/}" # MEDIUM-fix: strip trailing slash so
|
|
# ${FORGEJO_URL}/path doesn't double-slash
|
|
FORGEJO_USER="${KEI_FORGEJO_USER:-${USER}}"
|
|
# Keychain service names — single source. Override per-host via env.
|
|
KC_TOKEN_SERVICE="${KEI_FORGEJO_KC_TOKEN_SERVICE:-forgejo-api-token}"
|
|
KC_PASS_SERVICE="${KEI_FORGEJO_KC_PASS_SERVICE:-forgejo-admin-password}"
|
|
GITIGNORE_SHA="576334520435382d6522f349b9d270eda1e79a25"
|
|
GITIGNORE_BASE="https://raw.githubusercontent.com/github/gitignore/${GITIGNORE_SHA}"
|
|
GITIGNORE_CACHE="/tmp/kei-gdrive-gitignore-cache"
|
|
STAGING_ROOT="/tmp/kei-gdrive-import"
|
|
LEDGER_FILE="${KIT_DIR}/var/kei-drive-import-ledger.csv"
|
|
GITIGNORE_MAP="${KIT_DIR}/_templates/drive-import-gitignore-map.txt"
|
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
|
|
|
err() { printf 'kei-drive-import: ERROR: %s\n' "$*" >&2; }
|
|
info() { printf '%s\n' "$*"; }
|
|
die() { err "$*"; exit 1; }
|
|
ask_yn() {
|
|
local reply=""
|
|
printf '%s [y/N] ' "$1" >&2
|
|
read -r reply || reply=""
|
|
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
step0_preflight() {
|
|
info "==> Step 0: Preflight"
|
|
[ -f "$SECRETS_FILE" ] || die "secrets file not found: $SECRETS_FILE (RULE 0.8)"
|
|
# shellcheck disable=SC1090
|
|
set -a; . "$SECRETS_FILE"; set +a
|
|
[ -n "${RCLONE_CONFIG:-}" ] || die "RCLONE_CONFIG not set in $SECRETS_FILE (PLAN.md Wave 2 R2)"
|
|
[ -n "${KEI_DRIVE_REMOTE:-}" ] || die "KEI_DRIVE_REMOTE not set (e.g. KEI_DRIVE_REMOTE=gdrive)"
|
|
[ -f "$RCLONE_CONFIG" ] || die "rclone config missing at $RCLONE_CONFIG — run: rclone --config $RCLONE_CONFIG config"
|
|
local missing=""
|
|
for bin in rclone jq gitleaks kei-gdrive-import curl git; do
|
|
command -v "$bin" >/dev/null 2>&1 || missing="$missing $bin"
|
|
done
|
|
if [ -n "$missing" ]; then
|
|
err "missing binaries:$missing"
|
|
die "run install/lib-dev-hub-gdrive-import.sh first"
|
|
fi
|
|
[ -f "$GITIGNORE_MAP" ] || die "gitignore map not found: $GITIGNORE_MAP"
|
|
curl -sf -o /dev/null --max-time 5 "${FORGEJO_URL}/api/v1/version" \
|
|
|| die "Forgejo not reachable at ${FORGEJO_URL} — start dev-hub first"
|
|
mkdir -p "$STAGING_ROOT" "$GITIGNORE_CACHE" "$(dirname "$LEDGER_FILE")"
|
|
info " secrets sourced, binaries present, Forgejo reachable."
|
|
}
|
|
|
|
step1_remote_check() {
|
|
info "==> Step 1: rclone remote check"
|
|
if ! rclone --config "$RCLONE_CONFIG" listremotes 2>/dev/null | grep -q "^${KEI_DRIVE_REMOTE}:$"; then
|
|
err "remote '${KEI_DRIVE_REMOTE}:' not found in $RCLONE_CONFIG"
|
|
info " add via: rclone --config $RCLONE_CONFIG config"
|
|
info " name=${KEI_DRIVE_REMOTE} storage=drive scope=drive.readonly (auto-config opens browser)"
|
|
die "remote not configured"
|
|
fi
|
|
local about_out
|
|
about_out=$(rclone --config "$RCLONE_CONFIG" about "${KEI_DRIVE_REMOTE}:" 2>&1 || true)
|
|
if printf '%s\n' "$about_out" | grep -qiE 'oauth2|401|token'; then
|
|
err "rclone token appears expired (oauth2/401/token in 'rclone about')"
|
|
info " re-auth: rclone --config $RCLONE_CONFIG config reconnect ${KEI_DRIVE_REMOTE}:"
|
|
die "token expired"
|
|
fi
|
|
info " remote '${KEI_DRIVE_REMOTE}:' OK."
|
|
}
|
|
|
|
PROJECT_LIST=""
|
|
AMBIGUOUS_LIST=""
|
|
NOTPROJECT_COUNT=0
|
|
ALREADYREPO_COUNT=0
|
|
SCAN_ROOT=""
|
|
|
|
step2_scan() {
|
|
info "==> Step 2: scan"
|
|
SCAN_ROOT="${1:-${KEI_DRIVE_REMOTE}:Projects/}"
|
|
info " root: $SCAN_ROOT"
|
|
local folders
|
|
folders=$(rclone --config "$RCLONE_CONFIG" lsf --dirs-only "$SCAN_ROOT" 2>/dev/null || true)
|
|
[ -n "$folders" ] || die "no folders under $SCAN_ROOT (or rclone lsf failed)"
|
|
|
|
local gdoc_count
|
|
gdoc_count=$(rclone --config "$RCLONE_CONFIG" lsf "$SCAN_ROOT" \
|
|
--include "*.gdoc" --include "*.gsheet" --include "*.gslides" -R 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "${gdoc_count:-0}" -gt 0 ]; then
|
|
info " pre-flight: $gdoc_count Google-native files (.gdoc/.gsheet/.gslides) — SKIPPED by --drive-skip-gdocs."
|
|
if ask_yn " export gdocs as md? (unverified for current API)"; then
|
|
info " (gdoc-export not implemented in this build; skipping anyway.)"
|
|
fi
|
|
fi
|
|
|
|
info " classifying..."
|
|
local count=0 p_count=0 a_count=0 s_count=0 r_count=0 folder verdict
|
|
OLDIFS="$IFS"
|
|
IFS='
|
|
'
|
|
set -f
|
|
# shellcheck disable=SC2086
|
|
set -- $folders
|
|
set +f
|
|
IFS="$OLDIFS"
|
|
for raw in "$@"; do
|
|
folder="${raw%/}"
|
|
[ -z "$folder" ] && continue
|
|
count=$((count + 1))
|
|
verdict=$(kei-gdrive-import classify --remote "${SCAN_ROOT%/}/$folder" 2>/dev/null \
|
|
| jq -r '.verdict' 2>/dev/null || echo "ERROR")
|
|
case "$verdict" in
|
|
PROJECT)
|
|
p_count=$((p_count + 1))
|
|
PROJECT_LIST="${PROJECT_LIST}${folder}
|
|
" ;;
|
|
AMBIGUOUS)
|
|
a_count=$((a_count + 1))
|
|
AMBIGUOUS_LIST="${AMBIGUOUS_LIST}${folder}
|
|
" ;;
|
|
"NOT-A-PROJECT"|NOT_A_PROJECT) s_count=$((s_count + 1)) ;;
|
|
ALREADY-REPO|ALREADY_REPO|AlreadyRepo) r_count=$((r_count + 1)) ;;
|
|
*)
|
|
err "unknown verdict for '$folder': $verdict (treated as NOT-A-PROJECT)"
|
|
s_count=$((s_count + 1)) ;;
|
|
esac
|
|
done
|
|
NOTPROJECT_COUNT="$s_count"
|
|
ALREADYREPO_COUNT="$r_count"
|
|
info " total: $count PROJECT=$p_count AMBIGUOUS=$a_count NOT-A-PROJECT=$s_count AlreadyRepo=$r_count (skipped)"
|
|
}
|
|
|
|
SELECTED_LIST=""
|
|
step3_select() {
|
|
info "==> Step 3: select"
|
|
local p_count a_count
|
|
p_count=$(printf '%s' "$PROJECT_LIST" | grep -c .)
|
|
a_count=$(printf '%s' "$AMBIGUOUS_LIST" | grep -c .)
|
|
[ "$p_count" -eq 0 ] && [ "$a_count" -eq 0 ] && die "no PROJECT or AMBIGUOUS folders to import"
|
|
|
|
info ""
|
|
info " PROJECT folders ($p_count):"
|
|
local i=1 f
|
|
OLDIFS="$IFS"
|
|
IFS='
|
|
'
|
|
for f in $PROJECT_LIST; do
|
|
[ -z "$f" ] && continue
|
|
printf ' [%d] %s\n' "$i" "$f"
|
|
i=$((i + 1))
|
|
done
|
|
if [ "$a_count" -gt 0 ]; then
|
|
info ""
|
|
info " AMBIGUOUS folders ($a_count) — review carefully:"
|
|
for f in $AMBIGUOUS_LIST; do
|
|
[ -z "$f" ] && continue
|
|
printf ' [%d] %s\n' "$i" "$f"
|
|
i=$((i + 1))
|
|
done
|
|
fi
|
|
IFS="$OLDIFS"
|
|
|
|
info ""
|
|
info " Selection: 'all' / 'projects' (P only) / comma-list (e.g. 1,3,5) / 'none'"
|
|
local reply=""
|
|
printf ' > ' >&2
|
|
read -r reply || reply=""
|
|
case "$reply" in
|
|
none|NONE|"") die "user selected none — aborting" ;;
|
|
all|ALL) SELECTED_LIST="${PROJECT_LIST}${AMBIGUOUS_LIST}" ;;
|
|
projects|PROJECTS|p|P) SELECTED_LIST="$PROJECT_LIST" ;;
|
|
*)
|
|
SELECTED_LIST=""
|
|
local combined="${PROJECT_LIST}${AMBIGUOUS_LIST}"
|
|
local total idx_raw idx picked
|
|
total=$(printf '%s' "$combined" | grep -c .)
|
|
OLDIFS="$IFS"
|
|
IFS=','
|
|
set -f
|
|
# shellcheck disable=SC2086
|
|
set -- $reply
|
|
set +f
|
|
IFS="$OLDIFS"
|
|
for idx_raw in "$@"; do
|
|
idx=$(printf '%s' "$idx_raw" | tr -d ' ')
|
|
case "$idx" in ''|*[!0-9]*) err "invalid index: '$idx_raw'"; continue ;; esac
|
|
if [ "$idx" -lt 1 ] || [ "$idx" -gt "$total" ]; then
|
|
err "index out of range: $idx (1..$total)"; continue
|
|
fi
|
|
picked=$(printf '%s' "$combined" | sed -n "${idx}p")
|
|
[ -n "$picked" ] && SELECTED_LIST="${SELECTED_LIST}${picked}
|
|
"
|
|
done ;;
|
|
esac
|
|
|
|
local sel_count
|
|
sel_count=$(printf '%s' "$SELECTED_LIST" | grep -c .)
|
|
[ "$sel_count" -eq 0 ] && die "selection resolved to zero folders"
|
|
info ""
|
|
info " Will import $sel_count project(s) to $FORGEJO_URL as user '$FORGEJO_USER'."
|
|
ask_yn " Continue?" || die "user declined"
|
|
}
|
|
|
|
ledger_append() {
|
|
# ledger_append <project> <status> <forgejo_url> <staging_path>
|
|
local ts
|
|
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
[ -f "$LEDGER_FILE" ] || printf 'timestamp,project_name,status,forgejo_url,staging_path\n' >> "$LEDGER_FILE"
|
|
printf '%s,%s,%s,%s,%s\n' "$ts" "$1" "$2" "$3" "$4" >> "$LEDGER_FILE"
|
|
}
|
|
|
|
resolve_gitignore_template() {
|
|
local staging="$1" marker template
|
|
while IFS=$'\t' read -r marker template; do
|
|
[ -z "$marker" ] && continue
|
|
case "$marker" in '#'*) continue ;; esac
|
|
if [ -f "$staging/$marker" ]; then
|
|
printf '%s' "$template"; return 0
|
|
fi
|
|
done < "$GITIGNORE_MAP"
|
|
printf ''
|
|
}
|
|
|
|
fetch_gitignore_template() {
|
|
local template="$1" dest="$2" cached="${GITIGNORE_CACHE}/$1"
|
|
if [ ! -f "$cached" ]; then
|
|
curl -fsSL "${GITIGNORE_BASE}/${template}" -o "$cached" || return 1
|
|
fi
|
|
cp "$cached" "$dest"
|
|
}
|
|
|
|
forgejo_token() {
|
|
local tok=""
|
|
tok=$(security find-generic-password -s "$KC_TOKEN_SERVICE" -w 2>/dev/null || true)
|
|
if [ -n "$tok" ]; then printf '%s' "$tok"; return 0; fi
|
|
if [ -n "${KEI_FORGEJO_TOKEN:-}" ]; then printf '%s' "$KEI_FORGEJO_TOKEN"; return 0; fi
|
|
printf 'Forgejo API token not found in Keychain or $KEI_FORGEJO_TOKEN.\nPaste token (input hidden): ' >&2
|
|
stty -echo 2>/dev/null || true
|
|
read -r tok || tok=""
|
|
stty echo 2>/dev/null || true
|
|
printf '\n' >&2
|
|
[ -z "$tok" ] && return 1
|
|
if ask_yn "Store this token in macOS Keychain (service=$KC_TOKEN_SERVICE)?"; then
|
|
if security add-generic-password -s "$KC_TOKEN_SERVICE" -a "$FORGEJO_USER" -w "$tok" -U 2>/dev/null; then
|
|
printf 'Token stored in Keychain.\n' >&2
|
|
else
|
|
err "failed to store token in Keychain (continuing in-memory)"
|
|
fi
|
|
fi
|
|
printf '%s' "$tok"
|
|
}
|
|
|
|
size_ext_check() {
|
|
# returns 0=ok, 1=user-aborted
|
|
local staging="$1" total pdf media pct_pdf pct_media
|
|
total=$(find "$staging" -type f -not -path '*/.git/*' -exec stat -f%z {} \; 2>/dev/null \
|
|
| awk 'BEGIN{s=0}{s+=$1}END{print s+0}')
|
|
[ "${total:-0}" -eq 0 ] && return 0
|
|
pdf=$(find "$staging" -type f -name '*.pdf' -exec stat -f%z {} \; 2>/dev/null \
|
|
| awk 'BEGIN{s=0}{s+=$1}END{print s+0}')
|
|
media=$(find "$staging" -type f \
|
|
\( -name '*.mp4' -o -name '*.mov' -o -name '*.mkv' -o -name '*.iso' -o -name '*.zip' \) \
|
|
-exec stat -f%z {} \; 2>/dev/null | awk 'BEGIN{s=0}{s+=$1}END{print s+0}')
|
|
pct_pdf=$(( pdf * 100 / total ))
|
|
pct_media=$(( media * 100 / total ))
|
|
info " size: $total bytes; pdf=${pct_pdf}%, media=${pct_media}%"
|
|
if [ "$pct_pdf" -gt 50 ] || [ "$pct_media" -gt 30 ]; then
|
|
ask_yn " looks like third-party content (pdf>50% or media>30%); continue?" || return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
migrate_one() {
|
|
local proj="$1"
|
|
# Optional 2nd arg = full remote path (paths-mode passes it explicitly).
|
|
# Scan-mode legacy: derive from SCAN_ROOT + proj (relative folder name).
|
|
local src="${2:-${SCAN_ROOT%/}/${proj}}"
|
|
local staging="${STAGING_ROOT}/${proj}_${TIMESTAMP}"
|
|
local repo_url="${FORGEJO_URL}/${FORGEJO_USER}/${proj}.git"
|
|
info ""
|
|
info " --- $proj ---"
|
|
mkdir -p "$staging"
|
|
|
|
# 4.3 (.git existing-repo guard via remote first)
|
|
local has_git_remote
|
|
has_git_remote=$(rclone --config "$RCLONE_CONFIG" lsf --dirs-only --include ".git/" "$src" 2>/dev/null | wc -l | tr -d ' ')
|
|
if [ "${has_git_remote:-0}" -gt 0 ]; then
|
|
info " SKIP: source already contains .git/ — refusing to overwrite live repo"
|
|
ledger_append "$proj" "SKIPPED-ALREADY-REPO" "" "$staging"
|
|
return 0
|
|
fi
|
|
|
|
# 4.2 rclone copy (FROZEN flag block per PLAN.md R1)
|
|
info " rclone copy $src -> $staging"
|
|
if ! rclone --config "$RCLONE_CONFIG" copy "$src" "$staging" \
|
|
--drive-skip-gdocs \
|
|
--drive-skip-shortcuts \
|
|
--drive-skip-dangling-shortcuts \
|
|
--drive-acknowledge-abuse \
|
|
--exclude "**/.DS_Store" --exclude "**/._*" \
|
|
--exclude "**/Thumbs.db" --exclude "**/desktop.ini" \
|
|
--exclude "**/.Spotlight-V100/**" --exclude "**/.Trashes/**" --exclude "**/.fseventsd/**" \
|
|
--transfers 4 --checkers 8 --tpslimit 10 \
|
|
--retries 5 --low-level-retries 10 \
|
|
--checksum --create-empty-src-dirs \
|
|
--stats 5s --log-file "$staging/.rclone-import.log"
|
|
then
|
|
err "rclone copy failed for $proj"
|
|
ledger_append "$proj" "FAILED-RCLONE" "" "$staging"
|
|
return 1
|
|
fi
|
|
|
|
# 4.3 fallback: HEAD file present after copy
|
|
if [ -f "$staging/.git/HEAD" ]; then
|
|
info " SKIP: .git/HEAD found in staging (fallback) — refusing to re-init"
|
|
ledger_append "$proj" "SKIPPED-ALREADY-REPO" "" "$staging"
|
|
return 0
|
|
fi
|
|
|
|
# 4.4 size + ext histogram
|
|
if ! size_ext_check "$staging"; then
|
|
info " user aborted on size/ext warning"
|
|
ledger_append "$proj" "SKIPPED-USER" "" "$staging"
|
|
return 0
|
|
fi
|
|
|
|
# 4.5 secret scan
|
|
info " gitleaks scan..."
|
|
if ! gitleaks dir --no-banner --redact "$staging" >/dev/null 2>&1; then
|
|
err "gitleaks found secrets in $proj"
|
|
info " options: [s]kip this project / [a]bort all"
|
|
local reply=""
|
|
printf ' > ' >&2
|
|
read -r reply || reply=""
|
|
case "$reply" in
|
|
a|A|abort|ABORT)
|
|
ledger_append "$proj" "BLOCKED-SECRETS" "" "$staging"
|
|
die "user aborted batch on secret-scan failure" ;;
|
|
*)
|
|
ledger_append "$proj" "BLOCKED-SECRETS" "" "$staging"
|
|
return 1 ;;
|
|
esac
|
|
fi
|
|
|
|
# 4.6 apply gitignore
|
|
local template
|
|
template=$(resolve_gitignore_template "$staging")
|
|
if [ -n "$template" ]; then
|
|
info " applying $template"
|
|
fetch_gitignore_template "$template" "$staging/.gitignore" \
|
|
|| err "failed to fetch $template; continuing without .gitignore"
|
|
else
|
|
info " no marker matched; no language .gitignore applied"
|
|
fi
|
|
|
|
# 4.7 git init + commit (inside staging — separate repo, allowed at runtime)
|
|
(
|
|
cd "$staging" || exit 1
|
|
git init -b main >/dev/null 2>&1 || git init >/dev/null 2>&1
|
|
git symbolic-ref HEAD refs/heads/main 2>/dev/null || true
|
|
git add . >/dev/null 2>&1 || true
|
|
git -c user.name="kei-drive-import" -c user.email="import@local" \
|
|
commit -m "Import from Drive: $proj" >/dev/null 2>&1 || true
|
|
) || {
|
|
err "git init/commit failed for $proj"
|
|
ledger_append "$proj" "FAILED-GIT" "" "$staging"
|
|
return 1
|
|
}
|
|
|
|
# 4.8 forgejo create
|
|
local token
|
|
token=$(forgejo_token)
|
|
if [ -z "$token" ]; then
|
|
err "no Forgejo token available"
|
|
ledger_append "$proj" "BLOCKED-FORGEJO" "" "$staging"
|
|
return 1
|
|
fi
|
|
info " creating Forgejo repo..."
|
|
local http_code body_file
|
|
body_file=$(mktemp)
|
|
http_code=$(curl -sS -o "$body_file" -w '%{http_code}' \
|
|
-u "${FORGEJO_USER}:${token}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"name\":\"$proj\",\"auto_init\":false,\"private\":true}" \
|
|
"${FORGEJO_URL}/api/v1/user/repos" 2>/dev/null || echo "000")
|
|
case "$http_code" in
|
|
2*) info " repo created." ;;
|
|
409)
|
|
info " repo already exists on Forgejo (409); will push to existing."
|
|
ledger_append "$proj" "REPO-EXISTS" "$repo_url" "$staging" ;;
|
|
*)
|
|
err "Forgejo create failed: HTTP $http_code"
|
|
sed -n '1,3p' "$body_file" >&2 2>/dev/null || true
|
|
rm -f "$body_file"
|
|
ledger_append "$proj" "BLOCKED-FORGEJO" "" "$staging"
|
|
return 1 ;;
|
|
esac
|
|
rm -f "$body_file"
|
|
|
|
# 4.9 + 4.10 push (with remote-allowlist guard derived from FORGEJO_URL —
|
|
# case statement avoids regex-escaping URL meta chars; works in bash 3.2)
|
|
(
|
|
cd "$staging" || exit 1
|
|
git remote remove origin 2>/dev/null || true
|
|
git remote add origin "$repo_url"
|
|
local origin_url
|
|
origin_url=$(git remote get-url origin 2>/dev/null || echo "")
|
|
case "$origin_url" in
|
|
"${FORGEJO_URL}/"*) ;;
|
|
*)
|
|
printf 'kei-drive-import: ERROR: REJECTED: remote not allowlisted (expected %s/...): %s\n' \
|
|
"$FORGEJO_URL" "$origin_url" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
# Auth via http.extraHeader keeps the token off argv + git config + reflog.
|
|
# base64-encode "user:token" once; pass as ephemeral -c for this push only.
|
|
local basic_auth
|
|
basic_auth="$(printf '%s:%s' "$FORGEJO_USER" "$token" | base64 | tr -d '\n')"
|
|
git -c "http.extraHeader=Authorization: Basic ${basic_auth}" \
|
|
push -u origin main >/dev/null 2>&1 || exit 1
|
|
) || {
|
|
err "git push failed for $proj"
|
|
ledger_append "$proj" "FAILED-PUSH" "$repo_url" "$staging"
|
|
return 1
|
|
}
|
|
|
|
info " OK: pushed to $repo_url"
|
|
ledger_append "$proj" "OK" "$repo_url" "$staging"
|
|
return 0
|
|
}
|
|
|
|
OK_COUNT=0
|
|
SKIP_COUNT=0
|
|
BLOCK_COUNT=0
|
|
FAIL_COUNT=0
|
|
|
|
bucket_last() {
|
|
local last
|
|
last=$(tail -n 1 "$LEDGER_FILE" 2>/dev/null | awk -F',' '{print $3}')
|
|
case "$last" in
|
|
OK) OK_COUNT=$((OK_COUNT + 1)) ;;
|
|
SKIPPED-*|REPO-EXISTS) SKIP_COUNT=$((SKIP_COUNT + 1)) ;;
|
|
BLOCKED-*) BLOCK_COUNT=$((BLOCK_COUNT + 1)) ;;
|
|
FAILED-*) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
|
|
*) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
|
|
esac
|
|
}
|
|
|
|
step4_migrate() {
|
|
info "==> Step 4: migrate"
|
|
OLDIFS="$IFS"
|
|
IFS='
|
|
'
|
|
for proj in $SELECTED_LIST; do
|
|
[ -z "$proj" ] && continue
|
|
migrate_one "$proj" || true
|
|
bucket_last
|
|
done
|
|
IFS="$OLDIFS"
|
|
}
|
|
|
|
step5_report() {
|
|
info ""
|
|
info "==> Step 5: report"
|
|
info " OK : $OK_COUNT"
|
|
info " SKIPPED : $SKIP_COUNT"
|
|
info " BLOCKED : $BLOCK_COUNT"
|
|
info " FAILED : $FAIL_COUNT"
|
|
info " ledger : $LEDGER_FILE"
|
|
if [ "$BLOCK_COUNT" -gt 0 ] || [ "$FAIL_COUNT" -gt 0 ]; then
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
usage() {
|
|
cat <<USAGE
|
|
kei-drive-import — import folders from Google Drive into local Forgejo.
|
|
|
|
USAGE:
|
|
kei-drive-import [PATH...] # explicit paths (recommended)
|
|
kei-drive-import --paths-from FILE # paths from file (one per line)
|
|
kei-drive-import --scan [ROOT] # scan + classify mode
|
|
kei-drive-import --help
|
|
|
|
EXAMPLES:
|
|
# Import three explicit folders. No scan, no classify, just go.
|
|
kei-drive-import "gdrive:projects/MyApp" "gdrive:Work/clientA" "gdrive:foo/bar"
|
|
|
|
# Batch from file (# comments OK, blank lines OK)
|
|
kei-drive-import --paths-from ~/import-list.txt
|
|
|
|
# Discover mode: walk a root and ask which to import (legacy default)
|
|
kei-drive-import --scan "gdrive:projects/"
|
|
|
|
ENV:
|
|
KEI_DRIVE_REMOTE default rclone remote name (default: gdrive)
|
|
KEI_FORGEJO_USER forgejo owner (default: \$USER)
|
|
KEI_FORGEJO_URL forgejo URL (default: http://127.0.0.1:3001)
|
|
RCLONE_CONFIG rclone config path (required, set in .env)
|
|
USAGE
|
|
}
|
|
|
|
# Parse args. Modes: explicit-paths (default) / scan / paths-from / help.
|
|
MODE="paths"
|
|
SCAN_ARG=""
|
|
PATHS_FILE=""
|
|
EXPLICIT_PATHS=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-h|--help) usage; exit 0 ;;
|
|
--scan) MODE="scan"; SCAN_ARG="${2:-}"; [ -n "$SCAN_ARG" ] && shift; shift ;;
|
|
--paths-from)
|
|
MODE="paths"; PATHS_FILE="${2:-}"
|
|
[ -z "$PATHS_FILE" ] && die "--paths-from requires a file argument"
|
|
[ -f "$PATHS_FILE" ] || die "paths file not found: $PATHS_FILE"
|
|
shift 2 ;;
|
|
--) shift; while [ $# -gt 0 ]; do EXPLICIT_PATHS+=("$1"); shift; done ;;
|
|
-*) die "unknown flag: $1 (try --help)" ;;
|
|
*) EXPLICIT_PATHS+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
# Path-list mode: skip scan/classify, go straight to migrate. Each path is
|
|
# a literal rclone remote spec (e.g. "gdrive:projects/MyApp"). Repo name =
|
|
# last path component.
|
|
main_paths() {
|
|
local paths=()
|
|
if [ -n "$PATHS_FILE" ]; then
|
|
while IFS= read -r line; do
|
|
line="${line%%#*}" # strip comments
|
|
line="$(printf '%s' "$line" | awk '{$1=$1};1')" # trim
|
|
[ -n "$line" ] && paths+=("$line")
|
|
done < "$PATHS_FILE"
|
|
fi
|
|
if [ ${#EXPLICIT_PATHS[@]} -gt 0 ]; then
|
|
paths+=("${EXPLICIT_PATHS[@]}")
|
|
fi
|
|
if [ ${#paths[@]} -eq 0 ]; then
|
|
usage; die "no paths given (positional args or --paths-from FILE required)"
|
|
fi
|
|
step0_preflight
|
|
step1_remote_check
|
|
info "==> Step 2: paths-mode (${#paths[@]} explicit, scan/classify skipped)"
|
|
# Validate each remote path exists before any work
|
|
local p name
|
|
SELECTED_PROJECTS=()
|
|
for p in "${paths[@]}"; do
|
|
if ! rclone --config "$RCLONE_CONFIG" lsf --max-depth 1 "$p" >/dev/null 2>&1; then
|
|
die "path not found in remote: $p"
|
|
fi
|
|
name="$(basename "$p")"
|
|
info " + $p → repo '$name'"
|
|
SELECTED_PROJECTS+=("$p|$name")
|
|
done
|
|
step4_migrate_paths
|
|
step5_report && exit 0 || exit 1
|
|
}
|
|
|
|
# Migrate from SELECTED_PROJECTS (path|name pairs from main_paths).
|
|
step4_migrate_paths() {
|
|
info "==> Step 4: migrate (${#SELECTED_PROJECTS[@]} folder(s))"
|
|
local entry src name
|
|
for entry in "${SELECTED_PROJECTS[@]}"; do
|
|
src="${entry%%|*}"
|
|
name="${entry##*|}"
|
|
migrate_one "$name" "$src" || true
|
|
bucket_last
|
|
done
|
|
}
|
|
|
|
main() {
|
|
if [ "$MODE" = "scan" ]; then
|
|
step0_preflight
|
|
step1_remote_check
|
|
step2_scan "$SCAN_ARG"
|
|
step3_select
|
|
step4_migrate
|
|
step5_report && exit 0 || exit 1
|
|
else
|
|
main_paths
|
|
fi
|
|
}
|
|
|
|
main "$@"
|