First commit

This commit is contained in:
MaddoScientisto 2026-04-07 18:02:17 +02:00
commit f65a85dcc9
1468 changed files with 265316 additions and 128 deletions

291
sync/README.md Normal file
View file

@ -0,0 +1,291 @@
# Server Sync Plan
## Goal
Create a server-side Bash script that promotes files uploaded with WinSCP from a staging area in the user home directory into the live site tree, while keeping the live file ownership and permissions intact.
## Constraints
- The remote server is FreeBSD.
- Direct SSH-based sync from the local machine is unreliable because of the server banner and client compatibility issues.
- Uploads can only be done with WinSCP into the user-owned area.
- Final deployment into the live site requires elevation.
- The first version should cover these paths only:
- `www`
- `rus`
- `WEB-INF/classes`
- `RUS` uppercase should be ignored.
## Deployment Model
Use a two-step flow:
1. Upload changed files with WinSCP into a staging tree under `/home/marco/regalamiunsorriso`.
2. Log into the server and run a Bash script with `sudo` to promote the staged files into `/home/sites/regalamiunsorriso`.
This avoids client-side SSH problems and keeps all privileged filesystem changes on the server.
## Proposed Directory Layout
Staging root:
```text
/home/marco/regalamiunsorriso/
```
Live root:
```text
/home/sites/regalamiunsorriso/
```
Recommended staging layout:
```text
/home/marco/regalamiunsorriso/
incoming/
www/
rus/
WEB-INF/
classes/
logs/
bin/
```
Recommended script location:
```text
/home/marco/regalamiunsorriso/bin/promote-sync.sh
```
## First-Version Scope
The first script should be intentionally conservative.
It should:
- Process only files staged under `incoming/www`, `incoming/rus`, and `incoming/WEB-INF/classes`.
- Refuse to touch paths outside the live root.
- Ignore `RUS` uppercase completely.
- Update existing live files in place.
- Create parent directories only when they already exist in the allowed live trees or when their ownership and mode policy is explicit.
- Write a deployment log with timestamp, source path, destination path, and result.
It should not, in version 1:
- Delete live files.
- Try to sync the whole tree automatically.
- Touch `WEB-INF/lib`.
- Invent permissions for brand new files unless a clear policy is defined.
## Why Existing Files First
Keeping the original permissions intact is easy to guarantee for files that already exist in the live tree, because the script can read the current owner, group, and mode from the destination and reapply them after the content update.
New files are a separate problem. For them, there is no original metadata to preserve. The safe approach is:
- either block them in version 1,
- or allow them only through an explicit manifest with declared owner, group, and mode.
## Permission Strategy
For each staged file that maps to an existing live file:
1. Read current live metadata.
2. Copy the staged content to a temporary file under the destination directory.
3. Apply the original owner, group, and mode to the temporary file.
4. Atomically replace the live file.
5. Optionally verify that metadata still matches the original values.
On FreeBSD, metadata can be read with `stat -f`.
Example values to capture:
```bash
owner=$(stat -f '%Su' "$dest")
group=$(stat -f '%Sg' "$dest")
mode=$(stat -f '%Lp' "$dest")
```
Then reapply with:
```bash
chown "$owner:$group" "$tmp"
chmod "$mode" "$tmp"
mv -f "$tmp" "$dest"
```
This approach is safer than blindly using `cp -p`, because the script explicitly preserves the metadata already present on the live file.
## Safety Rules
The script should fail closed.
Required safeguards:
- `set -euo pipefail`
- hardcoded allowed roots only
- path normalization before use
- reject symlinks in the staging area for version 1
- reject any path containing `..`
- reject destinations that do not resolve under `/home/sites/regalamiunsorriso`
- dry-run mode
- per-file logging
- summary at the end with counts for updated, skipped, and failed files
- Exclude these symlinked subdirectories and internal `www` trees: `mypics`, `mypics-archivio`, `mypics2`, `www/_img/`, `www/_news/`, and `www/_tmp/`.
## Suggested Script Interface
```bash
./promote-sync.sh --dry-run
./promote-sync.sh --apply
./promote-sync.sh --apply --only www
./promote-sync.sh --apply --only rus
./promote-sync.sh --apply --only WEB-INF/classes
```
Suggested options:
- `--dry-run`: print actions without changing files
- `--apply`: execute the promotion
- `--only <scope>`: limit to one allowed subtree
- `--log <file>`: write to a specific log file
- `--allow-new <manifest>`: optional future extension for approved new files
## Recommended Promotion Algorithm
1. Set fixed values for `STAGE_ROOT` and `LIVE_ROOT`.
2. Build the list of allowed staging subtrees.
3. Walk staged files only.
4. For each staged file:
- map it to the live destination
- validate the destination is inside the live root
- verify the destination exists for version 1
- capture current owner, group, and mode from the live file
- copy staged content into a temporary file in the destination directory
- apply captured metadata
- replace the live file atomically
- log success or failure
5. Print a final summary.
6. Leave staged files in place unless an explicit cleanup option is requested.
## Logging Format
Plain text is enough for version 1.
Example:
```text
2026-03-28T21:10:03Z APPLY www/index.jsp -> /home/sites/regalamiunsorriso/www/index.jsp OK
2026-03-28T21:10:05Z APPLY rus/admin/menu/edit.jsp -> /home/sites/regalamiunsorriso/rus/admin/menu/edit.jsp OK
2026-03-28T21:10:06Z SKIP WEB-INF/classes/new.properties reason=destination-missing
```
## Operational Workflow
1. Prepare changed files locally.
2. Upload only those files with WinSCP into the mirrored staging tree under `/home/marco/regalamiunsorriso/incoming`.
3. Log into the server.
4. Run a dry run first.
5. Review the output.
6. Run the apply mode with `sudo`.
7. Verify the live site.
8. Archive or clean the staged files only after validation.
## Open Decisions Before Script Implementation
These should be fixed before writing version 1 of the script:
- Whether Bash is available as `/usr/bin/env bash` or needs an absolute FreeBSD package path such as `/usr/local/bin/bash`.
- Whether `sudo` can run the script directly as root or only specific commands.
- Whether brand new files should be blocked entirely or allowed through a manifest.
- Where logs should live permanently.
- Whether there are any directories inside `www` or `rus` that must never be deployed from staging.
## Proposed Implementation Phases
### Phase 1
- Write the promotion script.
- Support dry run and apply mode.
- Support existing-file updates only.
- Support `www`, `rus`, and `WEB-INF/classes`.
- Add logging and path safety checks.
### Phase 2
- Add manifest support for approved new files.
- Add optional cleanup or archive of deployed staged files.
- Add per-scope execution and better summaries.
### Phase 3
- Add optional checksum comparison to skip unchanged content.
- Add rollback support via timestamped backup copies.
- Add a small local helper to prepare the WinSCP staging tree from a list of changed files.
## Recommended Next Artifact
The next file to create should be the actual server script in `sync/promote-sync.sh`, based directly on the rules above.
## Initial-Copy Mode and Permission Database
Version 1 must also include a conservative `--init-sync` mode that copies the live site into the user staging area and records the original live-file metadata into a permission database. This achieves two goals:
- lets `marco` receive a full copy of the trees for local editing with readable files;
- captures original owner/group/mode so the promotion step can reapply them exactly.
Placement and format
- Permission database path (suggested): `/home/marco/regalamiunsorriso/metadata/perms.jsonl` (newline-delimited JSON). Store a backup as `/home/marco/regalamiunsorriso/metadata/perms.jsonl.bak` before each init-sync.
- Each record should contain: `relpath`, `owner`, `group`, `mode` (octal string), `type` (`file`|`dir`), and optional `sha256`.
Example record:
```json
{"relpath":"www/index.jsp","owner":"www","group":"www","mode":"0644","type":"file","sha256":"..."}
```
Init-sync behavior
1. Validate the script is run with sufficient privilege (the script should be invoked with `sudo` for this mode).
2. Build the list of allowed live subtrees (only `www`, `rus`, `WEB-INF/classes`).
3. Walk the live tree and for each file/dir:
- record metadata into the `perms.jsonl` file
- copy the content into the staging `incoming/` tree preserving relative paths
- after copy, `chown marco:marco` and set permissive modes so `marco` can read and edit (files `0644`, dirs `0755`) — this provides a safe editable copy for WinSCP uploads
4. Ensure symlinked exclusions (e.g., `mypics*`) are skipped during copy.
Promotion behavior (apply step uses the DB)
1. When promoting a staged file back to live, the script looks up the matching `relpath` in `perms.jsonl`.
2. If a record is found, the script will:
- write the staged content to a temporary file under the destination directory
- apply the recorded `owner:group` and `mode` to the temporary file
- atomically move the temporary file over the live file
3. If no record exists for a path and `--allow-new` is not used, the script should skip the file and log a `destination-missing-perms` reason.
Security and sanity
- The `--init-sync` operation must be explicit and dangerous-only: require `--apply-init` or a clear confirmation step.
- Keep a timestamped copy of the permission DB for quick audits and possible rollbacks.
- Validate the `owner:group` values read from the live system are valid accounts on the target system before applying them during promotion.
Small example usages
```bash
# create staging and record current live metadata (requires sudo)
sudo ./promote-sync.sh --init-sync --perms-db /home/marco/regalamiunsorriso/metadata/perms.jsonl --apply-init
# dry-run promotion using the recorded perms
./promote-sync.sh --dry-run --perms-db /home/marco/regalamiunsorriso/metadata/perms.jsonl
# actual promotion
sudo ./promote-sync.sh --apply --perms-db /home/marco/regalamiunsorriso/metadata/perms.jsonl
```
Notes
- This design keeps all privileged operations on the server and ensures files copied for local editing are readable by `marco` while preserving authoritative live permissions for when changes are promoted.
- Later phases may add ACL support or an allowlist manifest to permit well-audited new files to be created on the live site with declared metadata.

614
sync/promote-sync.sh Normal file
View file

@ -0,0 +1,614 @@
#!/bin/sh
set -eu
IFS='
'
SCRIPT_NAME=$(basename "$0")
STAGE_ROOT=${SYNC_STAGE_ROOT:-/home/marco/regalamiunsorriso}
LIVE_ROOT=${SYNC_LIVE_ROOT:-/home/sites/regalamiunsorriso}
STAGE_USER=${SYNC_STAGE_USER:-marco}
INCOMING_ROOT=${SYNC_INCOMING_ROOT:-$STAGE_ROOT/incoming}
LOG_FILE=${SYNC_LOG_FILE:-$STAGE_ROOT/logs/promote-sync.log}
PERMS_DB=${SYNC_PERMS_DB:-$STAGE_ROOT/metadata/perms.jsonl}
MODE=
PROMOTE_EXECUTE=0
INIT_EXECUTE=0
ONLY_SCOPE=
RESOLVED_LIVE_ROOT=
UPDATED_COUNT=0
SKIPPED_COUNT=0
FAILED_COUNT=0
usage() {
cat <<EOF
Usage:
$SCRIPT_NAME --dry-run [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --apply [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --init-sync [--only <scope>] [--perms-db <path>] [--log <path>]
$SCRIPT_NAME --init-sync --apply-init [--only <scope>] [--perms-db <path>] [--log <path>]
Modes:
--dry-run Preview staged file promotion into the live tree.
--apply Promote staged files into the live tree.
--init-sync Preview initial copy from live tree into staging.
--apply-init Execute the initial copy when used with --init-sync.
Options:
--only <scope> Limit processing to one scope: www, rus, WEB-INF/classes.
--perms-db <path> Metadata database location. Default: $PERMS_DB
--log <path> Log file location. Default: $LOG_FILE
--stage-root <path> Override staging root. Default: $STAGE_ROOT
--live-root <path> Override live root. Default: $LIVE_ROOT
--stage-user <user> Override staging owner. Default: $STAGE_USER
--help Show this help.
Environment overrides:
SYNC_STAGE_ROOT, SYNC_LIVE_ROOT, SYNC_STAGE_USER, SYNC_INCOMING_ROOT,
SYNC_LOG_FILE, SYNC_PERMS_DB
EOF
}
timestamp_utc() {
date -u +"%Y-%m-%dT%H:%M:%SZ"
}
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
ensure_log_dir() {
mkdir -p "$(dirname -- "$LOG_FILE")"
}
log_line() {
ensure_log_dir
printf '%s\n' "$1" | tee -a "$LOG_FILE"
}
stat_owner() {
if stat -f '%Su' "$1" >/dev/null 2>&1; then
stat -f '%Su' "$1"
else
stat -c '%U' "$1"
fi
}
stat_group() {
if stat -f '%Sg' "$1" >/dev/null 2>&1; then
stat -f '%Sg' "$1"
else
stat -c '%G' "$1"
fi
}
stat_mode() {
_mode=
if stat -f '%Lp' "$1" >/dev/null 2>&1; then
_mode=$(stat -f '%Lp' "$1")
else
_mode=$(stat -c '%a' "$1")
fi
printf '%04d\n' "$_mode"
}
resolve_existing_path() {
if command -v realpath >/dev/null 2>&1; then
realpath "$1"
return
fi
_dir_path=$(dirname -- "$1")
_base_name=$(basename -- "$1")
(
cd "$_dir_path"
printf '%s/%s\n' "$(pwd -P)" "$_base_name"
)
}
is_under_root() {
case "$1" in
"$2"|"$2"/*)
return 0
;;
*)
return 1
;;
esac
}
require_root() {
if [ "$(id -u)" -ne 0 ]; then
fail "this mode must run under sudo or as root"
fi
}
is_allowed_scope() {
case "$1" in
www|rus|WEB-INF/classes)
return 0
;;
*)
return 1
;;
esac
}
run_for_selected_scopes() {
_callback=$1
if [ -n "$ONLY_SCOPE" ]; then
"$_callback" "$ONLY_SCOPE"
return
fi
"$_callback" "www"
"$_callback" "rus"
"$_callback" "WEB-INF/classes"
}
is_excluded_relpath() {
case "$1" in
www/_img|www/_img/*|www/_news|www/_news/*|www/_tmp|www/_tmp/*|www/mypics|www/mypics/*|www/mypics-archivio|www/mypics-archivio/*|www/mypics2|www/mypics2/*)
return 0
;;
*)
return 1
;;
esac
}
is_safe_relpath() {
case "$1" in
''|/*|../*|*/../*|*/..|*//*)
return 1
;;
esac
case "$1" in
*/../*|*/..)
return 1
;;
*)
return 0
;;
esac
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
sha256_file() {
if command -v sha256 >/dev/null 2>&1; then
sha256 -q "$1"
elif command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
else
printf ''
fi
}
db_backup_existing() {
_metadata_dir=$(dirname -- "$PERMS_DB")
mkdir -p "$_metadata_dir"
if [ -f "$PERMS_DB" ]; then
_backup_path="$PERMS_DB.$(date -u +%Y%m%dT%H%M%SZ).bak"
cp "$PERMS_DB" "$_backup_path"
log_line "$(timestamp_utc) INFO perms-db-backup path=$_backup_path"
fi
}
db_append_record() {
printf '{"relpath":"%s","owner":"%s","group":"%s","mode":"%s","type":"%s","sha256":"%s"}\n' \
"$(json_escape "$1")" \
"$(json_escape "$2")" \
"$(json_escape "$3")" \
"$(json_escape "$4")" \
"$(json_escape "$5")" \
"$(json_escape "${6:-}")" >> "$PERMS_DB"
}
db_lookup_line() {
awk -v target="\"relpath\":\"$1\"" '
index($0, target) {
print
found = 1
exit
}
END {
if (!found) {
exit 1
}
}
' "$PERMS_DB"
}
db_extract_field() {
printf '%s\n' "$1" | awk -F'"' -v lookup_key="$2" '
{
for (i = 2; i < NF; i += 4) {
if ($i == lookup_key) {
print $(i + 2)
exit
}
}
}
'
}
user_exists() {
id "$1" >/dev/null 2>&1
}
group_exists() {
if command -v getent >/dev/null 2>&1; then
getent group "$1" >/dev/null 2>&1
return
fi
if command -v pw >/dev/null 2>&1; then
pw groupshow "$1" >/dev/null 2>&1
return
fi
return 0
}
record_live_metadata() {
_live_path=$1
_relpath=$2
_entry_type=$3
_owner=$(stat_owner "$_live_path")
_group=$(stat_group "$_live_path")
_mode=$(stat_mode "$_live_path")
_checksum=
if [ "$_entry_type" = "file" ]; then
_checksum=$(sha256_file "$_live_path")
fi
db_append_record "$_relpath" "$_owner" "$_group" "$_mode" "$_entry_type" "$_checksum"
}
log_skip() {
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
log_line "$(timestamp_utc) SKIP $1 reason=$2"
}
log_failure() {
FAILED_COUNT=$((FAILED_COUNT + 1))
log_line "$(timestamp_utc) FAIL $1 reason=$2"
}
log_update() {
UPDATED_COUNT=$((UPDATED_COUNT + 1))
log_line "$(timestamp_utc) $1 $2 -> $3 OK"
}
validate_path_policy() {
if ! is_safe_relpath "$1"; then
return 1
fi
if is_excluded_relpath "$1"; then
return 1
fi
case "$1" in
RUS|RUS/*)
return 1
;;
*)
return 0
;;
esac
}
ensure_parent_directory_exists() {
[ -d "$(dirname -- "$1")" ]
}
copy_live_entry_to_stage() {
_live_path=$1
_relpath=$2
_stage_path=$3
_entry_type=$4
if [ "$_entry_type" = "dir" ]; then
mkdir -p "$_stage_path"
chown "$STAGE_USER" "$_stage_path"
chmod 0755 "$_stage_path"
log_update "INIT-DIR" "$_relpath" "$_stage_path"
return
fi
mkdir -p "$(dirname -- "$_stage_path")"
cp "$_live_path" "$_stage_path"
chown "$STAGE_USER" "$_stage_path"
chmod 0644 "$_stage_path"
log_update "INIT" "$_relpath" "$_stage_path"
}
run_init_scope() {
_scope=$1
_live_scope_root="$LIVE_ROOT/$_scope"
_stage_scope_root="$INCOMING_ROOT/$_scope"
if [ ! -e "$_live_scope_root" ]; then
log_skip "$_scope" "live-scope-missing"
return
fi
_entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.init.XXXXXX")
find "$_live_scope_root" \( -type d -o -type f -o -type l \) > "$_entry_list"
while IFS= read -r _entry || [ -n "$_entry" ]; do
_relpath=${_entry#"$LIVE_ROOT/"}
if ! validate_path_policy "$_relpath"; then
log_skip "$_relpath" "excluded-or-unsafe"
continue
fi
if [ -L "$_entry" ]; then
log_skip "$_relpath" "symlink-excluded"
continue
fi
if ! is_under_root "$(resolve_existing_path "$_entry")" "$RESOLVED_LIVE_ROOT"; then
log_failure "$_relpath" "outside-live-root"
continue
fi
_stage_path="$INCOMING_ROOT/$_relpath"
if [ -d "$_entry" ]; then
if [ "$INIT_EXECUTE" -eq 1 ]; then
record_live_metadata "$_entry" "$_relpath" "dir"
copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "dir"
else
log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path"
fi
continue
fi
if [ "$INIT_EXECUTE" -eq 1 ]; then
record_live_metadata "$_entry" "$_relpath" "file"
copy_live_entry_to_stage "$_entry" "$_relpath" "$_stage_path" "file"
else
log_line "$(timestamp_utc) INIT-DRY-RUN $_relpath -> $_stage_path"
fi
done < "$_entry_list"
rm -f "$_entry_list"
if [ "$INIT_EXECUTE" -eq 1 ]; then
mkdir -p "$_stage_scope_root"
chown "$STAGE_USER" "$_stage_scope_root"
chmod 0755 "$_stage_scope_root"
fi
}
run_init_sync() {
if [ "$INIT_EXECUTE" -eq 1 ]; then
require_root
fi
RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT")
if [ "$INIT_EXECUTE" -eq 1 ]; then
mkdir -p "$INCOMING_ROOT"
db_backup_existing
: > "$PERMS_DB"
fi
run_for_selected_scopes run_init_scope
}
apply_metadata_to_temp() {
if ! user_exists "$2"; then
fail "owner '$2' from permissions database does not exist"
fi
if ! group_exists "$3"; then
fail "group '$3' from permissions database does not exist"
fi
chown "$2:$3" "$1"
chmod "$4" "$1"
}
promote_staged_file() {
_stage_file=$1
_relpath=$2
_dest_file=$3
_resolved_dest=$(resolve_existing_path "$_dest_file")
if ! is_under_root "$_resolved_dest" "$RESOLVED_LIVE_ROOT"; then
log_failure "$_relpath" "outside-live-root"
return
fi
if [ ! -f "$PERMS_DB" ]; then
log_failure "$_relpath" "perms-db-missing"
return
fi
if ! _db_line=$(db_lookup_line "$_relpath"); then
log_skip "$_relpath" "destination-missing-perms"
return
fi
_owner=$(db_extract_field "$_db_line" "owner")
_group=$(db_extract_field "$_db_line" "group")
_mode=$(db_extract_field "$_db_line" "mode")
if [ "$PROMOTE_EXECUTE" -eq 0 ]; then
log_line "$(timestamp_utc) DRY-RUN $_relpath -> $_dest_file owner=$_owner group=$_group mode=$_mode"
return
fi
_tmp_file=$(mktemp "$(dirname -- "$_dest_file")/.promote-sync.XXXXXX")
cp "$_stage_file" "$_tmp_file"
apply_metadata_to_temp "$_tmp_file" "$_owner" "$_group" "$_mode"
mv -f "$_tmp_file" "$_dest_file"
log_update "APPLY" "$_relpath" "$_dest_file"
}
run_promote_scope() {
_scope=$1
_stage_scope_root="$INCOMING_ROOT/$_scope"
if [ ! -d "$_stage_scope_root" ]; then
log_skip "$_scope" "staging-scope-missing"
return
fi
_entry_list=$(mktemp "${TMPDIR:-/tmp}/promote-sync.promote.XXXXXX")
find "$_stage_scope_root" \( -type f -o -type l \) > "$_entry_list"
while IFS= read -r _entry || [ -n "$_entry" ]; do
_relpath=${_entry#"$INCOMING_ROOT/"}
if ! validate_path_policy "$_relpath"; then
log_skip "$_relpath" "excluded-or-unsafe"
continue
fi
if [ -L "$_entry" ]; then
log_skip "$_relpath" "symlink-excluded"
continue
fi
_dest_file="$LIVE_ROOT/$_relpath"
if [ ! -e "$_dest_file" ]; then
log_skip "$_relpath" "destination-missing"
continue
fi
if ! ensure_parent_directory_exists "$_dest_file"; then
log_skip "$_relpath" "destination-parent-missing"
continue
fi
promote_staged_file "$_entry" "$_relpath" "$_dest_file"
done < "$_entry_list"
rm -f "$_entry_list"
}
run_promote() {
if [ "$PROMOTE_EXECUTE" -eq 1 ]; then
require_root
fi
RESOLVED_LIVE_ROOT=$(resolve_existing_path "$LIVE_ROOT")
run_for_selected_scopes run_promote_scope
}
print_summary() {
log_line "$(timestamp_utc) SUMMARY updated=$UPDATED_COUNT skipped=$SKIPPED_COUNT failed=$FAILED_COUNT mode=${MODE:-unknown}"
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=promote
PROMOTE_EXECUTE=0
;;
--apply)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=promote
PROMOTE_EXECUTE=1
;;
--init-sync)
[ -z "$MODE" ] || fail "choose only one primary mode"
MODE=init
;;
--apply-init)
INIT_EXECUTE=1
;;
--only)
shift
[ "$#" -gt 0 ] || fail "--only requires a scope"
ONLY_SCOPE=$1
is_allowed_scope "$ONLY_SCOPE" || fail "unsupported scope '$ONLY_SCOPE'"
;;
--perms-db)
shift
[ "$#" -gt 0 ] || fail "--perms-db requires a path"
PERMS_DB=$1
;;
--log)
shift
[ "$#" -gt 0 ] || fail "--log requires a path"
LOG_FILE=$1
;;
--stage-root)
shift
[ "$#" -gt 0 ] || fail "--stage-root requires a path"
STAGE_ROOT=$1
INCOMING_ROOT=$STAGE_ROOT/incoming
;;
--live-root)
shift
[ "$#" -gt 0 ] || fail "--live-root requires a path"
LIVE_ROOT=$1
;;
--stage-user)
shift
[ "$#" -gt 0 ] || fail "--stage-user requires a user"
STAGE_USER=$1
;;
--help|-h)
usage
exit 0
;;
*)
fail "unknown argument: $1"
;;
esac
shift
done
}
main() {
parse_args "$@"
[ -n "$MODE" ] || fail "choose one of --dry-run, --apply, or --init-sync"
if [ "$INIT_EXECUTE" -eq 1 ] && [ "$MODE" != "init" ]; then
fail "--apply-init requires --init-sync"
fi
if [ "$MODE" = "init" ] && [ "$INIT_EXECUTE" -eq 0 ]; then
log_line "$(timestamp_utc) INFO init-sync-preview only; add --apply-init to write staging files and permissions db"
fi
case "$MODE" in
init)
run_init_sync
;;
promote)
run_promote
;;
*)
fail "unsupported mode '$MODE'"
;;
esac
print_summary
}
main "$@"