Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a transfer tarball. This is the first commit: the Express server and its web front end, the Capacitor iOS and Android wrappers, and the release and backup scripts. The repo is the source, not a backup of the machine. Left out on purpose, by the .gitignore files that were already in the tree: node_modules, build output, mobile/www (regenerated from server/public on every build), and server/data, which holds the live checklists and belongs on the NAS rather than in git. Every credential is excluded too — server/.env, the Play upload keystore and its properties file, the App Store Connect key and ios-signing.config. The one secret that was not in an ignored file was the NAS SSH password, hard-coded in build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read it from the login keychain (service checklist-nas, account Christian), falling back to NAS_PASS in the environment, so rotating it is one keychain entry rather than a history rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/bash
|
||||
# backup-to-drive.sh — copy the checklist's nightly backups off the NAS into Google Drive.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# The NAS writes data/backups/checklists-<date>.json every night at 03:30 and keeps 30.
|
||||
# That is a good backup of the data and a poor backup of the building: if the NAS dies,
|
||||
# every one of those 30 files dies with it. The Google Drive folder was meant to be the
|
||||
# off-site copy, but it was hand-made — and on 2026-09-16 it was discovered to have
|
||||
# stopped in July, ten weeks earlier, with nobody noticing. This closes that loop.
|
||||
#
|
||||
# Run by a LaunchAgent at 04:15 daily (after the NAS job). Also safe to run by hand.
|
||||
#
|
||||
# WHAT IT DOES NOT DO
|
||||
# It never writes to the NAS and never deletes anything there. The only thing it removes
|
||||
# is old copies in Drive beyond KEEP, and only ones matching the backup naming pattern.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
DRIVE_DIR="$HOME/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Apps/Backups/Checklist Backups"
|
||||
REMOTE_DIR="/volume2/docker/recurring-checklist/data/backups"
|
||||
NAS_HOST="10.1.1.5"
|
||||
NAS_USER="Christian"
|
||||
LOG="$HOME/Library/Logs/checklist-drive-backup.log"
|
||||
MAX_PER_RUN=10 # so a first run with a big gap can't hang for ages; it catches up next time
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; }
|
||||
say() { log "$*"; echo "$*"; }
|
||||
|
||||
say "── run start ──"
|
||||
|
||||
# The SSH password comes from the login keychain — the same entry build-deploy.sh reads, so
|
||||
# there is still one copy of the credential to rotate, and it is not in the project at all.
|
||||
# Store it with: security add-generic-password -U -s checklist-nas -a Christian -w
|
||||
NAS_PASS="${NAS_PASS:-$(security find-generic-password -s checklist-nas -a Christian -w 2>/dev/null || true)}"
|
||||
if [ -z "$NAS_PASS" ]; then
|
||||
say "✗ no NAS password in the keychain (service checklist-nas, account Christian)"
|
||||
say " store it with: security add-generic-password -U -s checklist-nas -a Christian -w"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Google Drive streams files on demand; if the app is signed out or not running the path
|
||||
# simply is not there. Writing to a path Drive is not watching would look like it worked and
|
||||
# sync nothing, so refuse rather than pretend.
|
||||
if [ ! -d "$DRIVE_DIR" ]; then
|
||||
say "✗ Drive folder not mounted: $DRIVE_DIR"
|
||||
say " Is Google Drive for Desktop running and signed in as media@waterschurch.org?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_remote() { # $1 = remote command; prints stdout, strips the pty's CRs
|
||||
local cmd="$1"
|
||||
expect <<EXP 2>/dev/null | tr -d '\r'
|
||||
set timeout 240
|
||||
log_user 1
|
||||
spawn bash -c "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=20 -o PreferredAuthentications=password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 $NAS_USER@$NAS_HOST \"$cmd\""
|
||||
expect {
|
||||
-nocase "password:" { send "$NAS_PASS\r"; exp_continue }
|
||||
eof
|
||||
}
|
||||
EXP
|
||||
}
|
||||
|
||||
# 1. What does the NAS have?
|
||||
REMOTE_LIST="$(run_remote "ls -1 $REMOTE_DIR/checklists-*.json 2>/dev/null | xargs -n1 basename" \
|
||||
| grep -E '^checklists-[0-9]{4}-[0-9]{2}-[0-9]{2}\.json$' | sort)"
|
||||
if [ -z "$REMOTE_LIST" ]; then say "✗ no backups found on the NAS (or SSH failed)"; exit 1; fi
|
||||
say "NAS has $(echo "$REMOTE_LIST" | wc -l | tr -d ' ') backup(s)"
|
||||
|
||||
# 2. Which are missing from Drive?
|
||||
MISSING=""
|
||||
while IFS= read -r f; do
|
||||
[ -s "$DRIVE_DIR/$f" ] || MISSING="$MISSING$f"$'\n'
|
||||
done <<< "$REMOTE_LIST"
|
||||
MISSING="$(printf '%s' "$MISSING" | sed '/^$/d')"
|
||||
|
||||
if [ -z "$MISSING" ]; then
|
||||
say "✓ Drive already has every NAS backup — nothing to do"
|
||||
else
|
||||
COUNT="$(echo "$MISSING" | wc -l | tr -d ' ')"
|
||||
say "$COUNT missing from Drive; copying up to $MAX_PER_RUN"
|
||||
n=0
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
n=$((n+1)); [ $n -gt $MAX_PER_RUN ] && { say "· stopping at $MAX_PER_RUN this run"; break; }
|
||||
TMP="$(mktemp -t checklist-backup)"; OUT="$(mktemp -t checklist-backup-json)"
|
||||
run_remote "cat $REMOTE_DIR/$f" > "$TMP"
|
||||
# expect echoes its own `spawn …` banner and the password prompt onto stdout before the
|
||||
# file's contents, so the capture is never pure JSON. Slice from the first { to the last }
|
||||
# and only then validate — a truncated or half-read file that *looks* like a backup is
|
||||
# worse than an obviously missing one.
|
||||
if node -e '
|
||||
const fs=require("fs");
|
||||
const t=fs.readFileSync(process.argv[1],"utf8");
|
||||
const a=t.indexOf("{"), b=t.lastIndexOf("}");
|
||||
if(a<0||b<=a) process.exit(1);
|
||||
const j=JSON.parse(t.slice(a,b+1)); // throws => exit non-zero
|
||||
if(!Array.isArray(j.lists)) process.exit(1); // shape check, not just valid JSON
|
||||
fs.writeFileSync(process.argv[2], JSON.stringify(j));
|
||||
' "$TMP" "$OUT" 2>/dev/null; then
|
||||
mv "$OUT" "$DRIVE_DIR/$f"; rm -f "$TMP"
|
||||
say " ✓ $f ($(du -k "$DRIVE_DIR/$f" | cut -f1) KB)"
|
||||
else
|
||||
rm -f "$TMP" "$OUT"
|
||||
say " ✗ $f — no valid checklist JSON in the response, skipped"
|
||||
fi
|
||||
done <<< "$MISSING"
|
||||
fi
|
||||
|
||||
# 3. Report only. This script DELIBERATELY DELETES NOTHING in Drive.
|
||||
#
|
||||
# An earlier version trimmed Drive to the newest 30 to mirror the NAS, and promptly destroyed
|
||||
# the June snapshots — which existed ONLY in Drive, because the NAS had already rotated them
|
||||
# out. That is backwards: the whole value of the off-site copy is that it outlives the NAS's
|
||||
# 30-day window. Each file is ~260 KB, so a decade of them is under a gigabyte. If it ever
|
||||
# genuinely needs trimming, do it by hand, and never below what the NAS still holds.
|
||||
cd "$DRIVE_DIR" || exit 1
|
||||
TOTAL="$(ls -1 checklists-????-??-??.json 2>/dev/null | wc -l | tr -d ' ')"
|
||||
OLDEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | head -1)"
|
||||
NEWEST="$(ls -1 checklists-????-??-??.json 2>/dev/null | sort | tail -1)"
|
||||
say "✓ done — $TOTAL copies off-site, ${OLDEST:-none} → ${NEWEST:-none}"
|
||||
Reference in New Issue
Block a user