#!/usr/bin/env bash # # Build a portable Linux migration bundle for an obsidian-wiki installation. # # The bundle contains: # - obsidian-wiki/ this repository, excluding local caches and symlink mirrors # - vault/ the configured vault, including _sources/ and _meta/ # - deploy-linux-server.sh # - server.env.example, README-LINUX-MIGRATION.md, bundle-info.env, SHA256SUMS # # It does not bundle the QMD cache. QMD is rebuilt on the server. set -Eeuo pipefail IFS=$'\n\t' SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEFAULT_REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$DEFAULT_REPO_DIR" VAULT_DIR="" OUT_DIR="$DEFAULT_REPO_DIR/dist" BUNDLE_NAME="" REBUILD_FILE_INDEX=1 RUN_BOT_AUDIT=1 WITHOUT_SOURCES=0 KEEP_STAGING=0 DRY_RUN=0 usage() { cat <<'EOF' Usage: scripts/package-linux-migration.sh [options] Options: --vault PATH Vault path to package. Defaults to OBSIDIAN_VAULT_PATH from repo .env, then ~/.obsidian-wiki/config. --repo PATH obsidian-wiki repo path. Defaults to this script's parent. --out DIR Output directory for the .tar.gz bundle. Default: ./dist --name NAME Bundle directory/archive name. Default: obsidian-wiki-linux- --skip-file-index Do not rebuild _meta/file-assets.sqlite before packaging. --skip-bot-audit Do not run bot-view audit before packaging. --without-sources Exclude vault/_sources/archive from the bundle. This is only for small test bundles; citations/file lookup suffer. --keep-staging Keep the temporary expanded bundle directory after tar. --dry-run Print resolved paths and checks, but do not copy or tar. -h, --help Show this help. Example: scripts/package-linux-migration.sh \ --vault "/Users/nactang/repo/knowlege Base/Knowlege Base" \ --out /Users/nactang/Desktop/wiki-migration EOF } die() { echo "ERROR: $*" >&2 exit 1 } info() { echo "[INFO] $*" } warn() { echo "[WARN] $*" >&2 } clean_config_value() { local value="$1" value="${value#\"}" value="${value%\"}" value="${value#\'}" value="${value%\'}" printf '%s' "$value" } expand_path() { local path="$1" case "$path" in "~") printf '%s' "$HOME" ;; "~/"*) printf '%s/%s' "$HOME" "${path#~/}" ;; *) printf '%s' "$path" ;; esac } read_config_key() { local file="$1" local key="$2" [[ -f "$file" ]] || return 1 awk -v key="$key" ' index($0, key "=") == 1 { sub("^[^=]*=", "", $0) print $0 exit } ' "$file" } resolve_vault_dir() { if [[ -n "$VAULT_DIR" ]]; then clean_config_value "$VAULT_DIR" return fi local raw="" raw="$(read_config_key "$REPO_DIR/.env" "OBSIDIAN_VAULT_PATH" || true)" if [[ -z "$raw" ]]; then raw="$(read_config_key "$HOME/.obsidian-wiki/config" "OBSIDIAN_VAULT_PATH" || true)" fi clean_config_value "$raw" } require_command() { local cmd="$1" command -v "$cmd" >/dev/null 2>&1 || die "missing command: $cmd" } human_size() { du -sh "$1" 2>/dev/null | awk '{print $1}' } file_count() { find "$1" -type f 2>/dev/null | wc -l | tr -d ' ' } run_obsidian_wiki() { (cd "$REPO_DIR" && PYTHONPATH="$REPO_DIR${PYTHONPATH:+:$PYTHONPATH}" python3 -m obsidian_wiki "$@") } while [[ $# -gt 0 ]]; do case "$1" in --vault) [[ $# -ge 2 ]] || die "--vault requires a path" VAULT_DIR="$2" shift 2 ;; --repo) [[ $# -ge 2 ]] || die "--repo requires a path" REPO_DIR="$2" shift 2 ;; --out) [[ $# -ge 2 ]] || die "--out requires a directory" OUT_DIR="$2" shift 2 ;; --name) [[ $# -ge 2 ]] || die "--name requires a value" BUNDLE_NAME="$2" shift 2 ;; --skip-file-index) REBUILD_FILE_INDEX=0 shift ;; --skip-bot-audit) RUN_BOT_AUDIT=0 shift ;; --without-sources) WITHOUT_SOURCES=1 shift ;; --keep-staging) KEEP_STAGING=1 shift ;; --dry-run) DRY_RUN=1 shift ;; -h|--help) usage exit 0 ;; *) die "unknown option: $1" ;; esac done REPO_DIR="$(expand_path "$REPO_DIR")" REPO_DIR="$(cd "$REPO_DIR" && pwd)" VAULT_DIR="$(resolve_vault_dir)" VAULT_DIR="$(expand_path "$VAULT_DIR")" OUT_DIR="$(expand_path "$OUT_DIR")" [[ -d "$REPO_DIR/obsidian_wiki" ]] || die "repo path does not look like obsidian-wiki: $REPO_DIR" [[ -n "$VAULT_DIR" ]] || die "vault path is not set; pass --vault PATH" [[ -d "$VAULT_DIR" ]] || die "vault path does not exist: $VAULT_DIR" [[ -f "$VAULT_DIR/index.md" ]] || warn "vault has no index.md: $VAULT_DIR" [[ -d "$VAULT_DIR/_meta" ]] || warn "vault has no _meta directory: $VAULT_DIR" [[ -d "$VAULT_DIR/_sources" ]] || warn "vault has no _sources directory: $VAULT_DIR" require_command python3 require_command rsync require_command tar require_command shasum if [[ -z "$BUNDLE_NAME" ]]; then BUNDLE_NAME="obsidian-wiki-linux-$(date -u +%Y%m%dT%H%M%SZ)" fi mkdir -p "$OUT_DIR" OUT_DIR="$(cd "$OUT_DIR" && pwd)" info "repo: $REPO_DIR" info "vault: $VAULT_DIR" info "vault size: $(human_size "$VAULT_DIR")" info "vault files: $(file_count "$VAULT_DIR")" info "out: $OUT_DIR" info "bundle: $BUNDLE_NAME.tar.gz" if [[ "$WITHOUT_SOURCES" -eq 1 ]]; then warn "--without-sources is enabled; _sources/archive will not be packaged." fi if [[ "$DRY_RUN" -eq 1 ]]; then info "dry run complete; no files copied." exit 0 fi if [[ "$REBUILD_FILE_INDEX" -eq 1 ]]; then info "rebuilding file asset index before packaging..." run_obsidian_wiki file-index rebuild --vault "$VAULT_DIR" fi if [[ "$RUN_BOT_AUDIT" -eq 1 ]]; then info "auditing bot-view ACLs before packaging..." run_obsidian_wiki bot-view audit --vault "$VAULT_DIR" fi WORK_DIR="$OUT_DIR/.staging-$BUNDLE_NAME" STAGE_DIR="$WORK_DIR/$BUNDLE_NAME" rm -rf "$WORK_DIR" mkdir -p "$STAGE_DIR" info "copying repo..." rsync -a \ --exclude '/.git/' \ --exclude '/.venv/' \ --exclude '/dist/' \ --exclude '/build/' \ --exclude '/.mypy_cache/' \ --exclude '/.pytest_cache/' \ --exclude '/__pycache__/' \ --exclude '*.pyc' \ --exclude '.DS_Store' \ --exclude '/.claude/skills/' \ --exclude '/.cursor/skills/' \ --exclude '/.windsurf/skills/' \ --exclude '/.agents/skills/' \ --exclude '/.pi/skills/' \ --exclude '/.kiro/skills/' \ "$REPO_DIR/" "$STAGE_DIR/obsidian-wiki/" info "copying vault..." VAULT_RSYNC_EXCLUDES=( --exclude '.DS_Store' --exclude '.trash/' --exclude '.obsidian/workspace*' ) if [[ "$WITHOUT_SOURCES" -eq 1 ]]; then VAULT_RSYNC_EXCLUDES+=(--exclude '_sources/archive/') fi rsync -a "${VAULT_RSYNC_EXCLUDES[@]}" "$VAULT_DIR/" "$STAGE_DIR/vault/" cp "$REPO_DIR/scripts/deploy-linux-server.sh" "$STAGE_DIR/deploy-linux-server.sh" chmod +x "$STAGE_DIR/deploy-linux-server.sh" cat > "$STAGE_DIR/server.env.example" <<'EOF' # Copy values from here into deploy options if you want non-default paths. INSTALL_DIR=/opt/obsidian-wiki VAULT_DIR=/srv/bjd-vault BOT_VIEW_DIR=/srv/bjd-bot-views QMD_WIKI_COLLECTION=bjd-wiki QMD_CLI_SEARCH_MODE=balanced SOURCE_FILE_PROFILES=quality-bot,engineering-bot EOF cat > "$STAGE_DIR/README-LINUX-MIGRATION.md" <<'EOF' # obsidian-wiki Linux migration bundle This bundle is meant for a headless Linux server. Obsidian Desktop is not required on the server. ## Deploy Unpack the archive on the server, then run: ```bash cd obsidian-wiki-linux-* sudo bash deploy-linux-server.sh \ --install-dir /opt/obsidian-wiki \ --vault-dir /srv/bjd-vault \ --bot-view-dir /srv/bjd-bot-views ``` For a user-local deployment without sudo: ```bash bash deploy-linux-server.sh \ --install-dir "$HOME/obsidian-wiki" \ --vault-dir "$HOME/bjd-vault" \ --bot-view-dir "$HOME/bjd-bot-views" ``` The deploy script copies the repo and vault, writes config, rebuilds the SQLite file index, audits bot ACLs, exports bot views, and rebuilds QMD collections if `qmd` is installed. EOF cat > "$STAGE_DIR/bundle-info.env" < SHA256SUMS find . -type f ! -name SHA256SUMS -print | LC_ALL=C sort | while IFS= read -r file; do shasum -a 256 "$file" done > SHA256SUMS ) BUNDLE_TAR="$OUT_DIR/$BUNDLE_NAME.tar.gz" rm -f "$BUNDLE_TAR" "$BUNDLE_TAR.sha256" info "creating tarball..." ( cd "$WORK_DIR" COPYFILE_DISABLE=1 tar -czf "$BUNDLE_TAR" "$BUNDLE_NAME" ) shasum -a 256 "$BUNDLE_TAR" > "$BUNDLE_TAR.sha256" if [[ "$KEEP_STAGING" -eq 0 ]]; then rm -rf "$WORK_DIR" else info "kept staging dir: $STAGE_DIR" fi info "bundle ready: $BUNDLE_TAR" info "checksum: $BUNDLE_TAR.sha256" cat <