KeiSeiKit-1.0/_templates/drive-import-wizard.sh.tmpl
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
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.
2026-05-01 12:09:03 +08:00

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 "$@"