feat: add safe one-click uninstall
TallyNote release / linux-x64 (push) Successful in 6m16s

This commit is contained in:
Qiufeng
2026-09-01 06:57:52 +08:00
parent 4434acf697
commit 4b9c80cc3a
10 changed files with 723 additions and 28 deletions
+2 -1
View File
@@ -32,10 +32,11 @@ cp -a migrations/. "$stage/migrations/"
cp package.json pnpm-lock.yaml "$stage/"
cp -a bin/. "$stage/bin/"
cp -a scripts/tallynote-update.sh scripts/tallynote-update-runner.sh "$stage/scripts/"
cp uninstall.sh "$stage/uninstall.sh"
cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/"
node_path=$(command -v node)
cp -L "$node_path" "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node"
chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh"
# pnpm's default linker creates symlinks. A release archive is deliberately
# symlink-free so the installer can reject traversal links deterministically.
+3
View File
@@ -82,10 +82,12 @@ bash -c '
source_tmp="$tmp/source"
mkdir -p "$source_tmp/dist/server" "$source_tmp/bin" "$source_tmp/scripts" "$source_tmp/runtime/bin"
printf '%s\n' 'server' > "$source_tmp/dist/server/index.js"
printf '%s\n' '#!/bin/sh' > "$source_tmp/uninstall.sh"
printf '%s\n' '#!/bin/sh' > "$source_tmp/bin/tallynote"
printf '%s\n' '#!/bin/sh' > "$source_tmp/scripts/runner.sh"
printf '%s\n' 'node' > "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/bin/tallynote" "$source_tmp/scripts/runner.sh" "$source_tmp/runtime/bin/node"
chmod 755 "$source_tmp/uninstall.sh"
archive_tmp="$tmp/release.tar.gz"
tar -C "$source_tmp" -czf "$archive_tmp" .
bash -c '
@@ -99,6 +101,7 @@ bash -c '
[[ "$(stat_mode "$destination/dist")" == 755 ]]
[[ "$(stat_mode "$destination/dist/server/index.js")" == 644 ]]
[[ "$(stat_mode "$destination/bin/tallynote")" == 755 ]]
[[ "$(stat_mode "$destination/uninstall.sh")" == 755 ]]
' _ "$installer_lib" "$archive_tmp" "$tmp/unpacked"
# A normal public-release install only needs the detached SHA-256 manifest;
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -Eeuo pipefail
root=$(cd -- "$(dirname -- "$0")/.." && pwd -P)
bash -n "$root/uninstall.sh"
tmp=$(cd "$(mktemp -d)" && pwd -P)
cleanup() { rm -rf -- "$tmp" 2>/dev/null || true; }
trap cleanup EXIT
make_fixture() {
local fixture=$1
mkdir -p "$fixture/opt/tallynote/releases/1.1.1/dist" \
"$fixture/opt/tallynote/.update-work" \
"$fixture/var/lib/tallynote/files" \
"$fixture/var/lib/tallynote/staging" \
"$fixture/var/lib/tallynote/exports" \
"$fixture/var/lib/tallynote-backups" \
"$fixture/etc/tallynote" \
"$fixture/etc/systemd/system" \
"$fixture/usr/local/sbin" \
"$fixture/usr/local/libexec"
printf '%s\n' 'release' > "$fixture/opt/tallynote/releases/1.1.1/dist/index.js"
ln -s "$fixture/opt/tallynote/releases/1.1.1" "$fixture/opt/tallynote/current"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
"TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$fixture/etc/tallynote/update-signing-key.pub" \
> "$fixture/etc/tallynote/tallynote.env"
chmod 600 "$fixture/etc/tallynote/tallynote.env"
printf '%s\n' 'fake public key' > "$fixture/etc/tallynote/update-signing-key.pub"
for unit in tallynote.service tallynote-update.service tallynote-update.path; do
printf '%s\n' "Description=TallyNote $unit" "WorkingDirectory=$fixture/opt/tallynote/current" "PathExists=$fixture/var/lib/tallynote/update-request.json" > "$fixture/etc/systemd/system/$unit"
done
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update"
printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner"
cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall"
chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall"
printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db"
printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db"
}
make_systemctl() {
local fixture=$1
cat > "$fixture/systemctl" <<'EOF'
#!/usr/bin/env bash
set -u
printf '%s\n' "$*" >> "$TALLYNOTE_TEST_SYSTEMCTL_LOG"
case "${1:-}" in
is-active) exit 0 ;;
stop|disable|daemon-reload) exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod 755 "$fixture/systemctl"
}
run_uninstall() {
local fixture=$1
TALLYNOTE_UNINSTALL_TEST_MODE=true \
TALLYNOTE_UNINSTALL_ROOT="$fixture" \
TALLYNOTE_SYSTEMCTL_BIN="$fixture/systemctl" \
TALLYNOTE_TEST_SYSTEMCTL_LOG="$fixture/systemctl.log" \
bash "$root/uninstall.sh" "${@:2}"
}
fixture="$tmp/normal"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture"
[[ -d "$fixture/var/lib/tallynote" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ -d "$fixture/var/lib/tallynote-backups" && -f "$fixture/var/lib/tallynote-backups/backup.db" ]]
[[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]]
[[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]]
[[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]]
grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null
grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null
path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
update_stop=$(grep -n '^stop tallynote-update.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
main_stop=$(grep -n '^stop tallynote.service' "$fixture/systemctl.log" | head -n1 | cut -d: -f1)
(( path_stop < update_stop && update_stop < main_stop ))
# Re-running after the first uninstall is harmless and does not touch data.
run_uninstall "$fixture"
[[ -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Purge requires the explicit acknowledgement flag and must fail before any
# application files are removed.
fixture="$tmp/purge"
make_fixture "$fixture"
make_systemctl "$fixture"
if run_uninstall "$fixture" --purge-data >/dev/null 2>&1; then
echo 'expected --purge-data without --yes to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
run_uninstall "$fixture" --purge-data --yes --purge-config
[[ ! -e "$fixture/var/lib/tallynote" && ! -e "$fixture/var/lib/tallynote-backups" ]]
[[ ! -e "$fixture/etc/tallynote" ]]
# A custom data path must not overlap the release prefix; otherwise removing
# releases could destroy data that the default uninstall promises to keep.
fixture="$tmp/overlap"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote" \
"TALLYNOTE_DATA_DIR=$fixture/opt/tallynote/releases/data" \
> "$fixture/etc/tallynote/tallynote.env"
mkdir -p "$fixture/opt/tallynote/releases/data"
printf '%s\n' protected > "$fixture/opt/tallynote/releases/data/keep.db"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected overlapping data path to fail' >&2
exit 1
fi
[[ -f "$fixture/opt/tallynote/releases/data/keep.db" ]]
# Trailing-slash aliases are rejected before the lexical overlap guard can be
# bypassed.
fixture="$tmp/trailing"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/" \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected trailing slash path to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Dot-component aliases are rejected as well; textual paths must be canonical
# before the managed-directory containment checks run.
fixture="$tmp/dot"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' \
"TALLYNOTE_INSTALL_PREFIX=$fixture/opt/tallynote/." \
"TALLYNOTE_DATA_DIR=$fixture/var/lib/tallynote" \
> "$fixture/etc/tallynote/tallynote.env"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected dot path component to fail' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# Pending update state blocks destructive work until an operator overrides it.
fixture="$tmp/pending"
make_fixture "$fixture"
make_systemctl "$fixture"
printf '%s\n' pending > "$fixture/opt/tallynote/.update-state"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected pending update state to block uninstall' >&2
exit 1
fi
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# A current link escaping the release tree is rejected without deleting data.
fixture="$tmp/link"
make_fixture "$fixture"
make_systemctl "$fixture"
rm -f "$fixture/opt/tallynote/current"
ln -s "$fixture/outside" "$fixture/opt/tallynote/current"
if run_uninstall "$fixture" >/dev/null 2>&1; then
echo 'expected unsafe current symlink to fail' >&2
exit 1
fi
[[ -L "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
# dry-run must not call systemctl or remove files.
fixture="$tmp/dry-run"
make_fixture "$fixture"
make_systemctl "$fixture"
run_uninstall "$fixture" --dry-run >/dev/null
[[ -e "$fixture/opt/tallynote/current" && -f "$fixture/var/lib/tallynote/tallynote.db" ]]
[[ ! -e "$fixture/systemctl.log" ]]
printf '%s\n' 'uninstaller shell tests passed'