scripts/install/flinttrade-uninstall.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# FlintTrade Electron shell uninstaller (macOS + Linux)
#
# Ordinary uninstall removes only the shell and launch integration. The trading
# workspace, Electron profile, managed source/toolchain, the contributor
# source-build checkout, the pre-workspace data directories and legacy desktop
# data and the stable installation identity/migration tombstones are all
# retained. --purge lists every resolved path and requires explicit confirmation
# before deleting them; installation lineage is the platform-default path only
# and is removed last, after every earlier data target was removed successfully.
# Destructive purge is currently refused: a verified protection-preserving
# maintenance controller is not yet available. --dry-run can still list scope.
#
# curl -fsSL https://flinttrade.vercel.app/uninstall.sh | bash
# curl -fsSL https://flinttrade.vercel.app/uninstall.sh | bash -s -- --purge
#
# The origin above is the last-resort public site. The hosted /uninstall.sh
# redirect stays valid on a custom domain; this uninstaller does not read
# FLINTTRADE_SITE_URL (the web installer does).
set -euo pipefail
LEGACY_BUNDLE_ID="com.flinttrade.app"
PURGE=0
ASSUME_YES=0
DRY_RUN="${FLINTTRADE_UNINSTALL_DRY_RUN:-0}"
REMOVED_ANY=0
SHELL_REMOVED_ANY=0
WEB_REMOVED_ANY=0
FAILED_ANY=0
PURGE_COMPLETED=0
PURGED_DATA_ANY=0
DATA_FOUND_ANY=0
DATA_RETAINED_ANY=0
LINEAGE_OVERLAP_FOUND=0
INSTALLATION_STATE_CANONICAL=""
DATA_TARGETS=()
SHELL_RECEIPT_DIR="$HOME/.local/state/flinttrade"
SHELL_RECEIPT_PATH="$SHELL_RECEIPT_DIR/shell-install.receipt"
SHELL_LAUNCH_LOG="$SHELL_RECEIPT_DIR/desktop-launch.log"
# The one-line web installer records everything it writes outside the managed
# root here (flinttrade-web-install.sh). Without it the launcher shim was
# orphaned residue: no uninstall path could prove it, so it was either left
# forever (macOS) or reported as an unprovable failure (Linux).
WEB_RECEIPT_DIR="$HOME/.local/state/flinttrade-web"
WEB_RECEIPT_PATH="$WEB_RECEIPT_DIR/web-install.receipt"
WEB_RECEIPT_VALID=0
WEB_SHIM_PROVEN=0
WEB_RECEIPT_RETAINED=0
WEB_RECEIPT_SHIM=""
WEB_RECEIPT_SHIM_SHA256=""
WEB_RECEIPT_SHORTCUT=""
WEB_RECEIPT_SOURCE=""
WEB_RECEIPT_TOOLS=""
RECEIPT_KIND=""
RECEIPT_TARGET=""
RECEIPT_EXECUTABLE=""
RECEIPT_EXECUTABLE_SHA256=""
RECEIPT_WRAPPER=""
RECEIPT_DESKTOP=""
RECEIPT_ICON=""
RECEIPT_ICON_SHA256=""
say() { printf '%s\n' "==> $*"; }
die() { printf '%s\n' "error: $*" >&2; exit 1; }
usage() {
cat <<'USAGE'
FlintTrade Electron shell uninstaller (macOS + Linux)
Flags:
--purge Also delete the workspace, Electron profile, managed source/tools,
the source-build checkout (~/.flinttrade/source-build, or
FLINTTRADE_SRC_DIR only when an installer receipt proves it is a
FlintTrade-managed checkout), the pre-workspace ~/.flinttrade
data/archive/sandbox directories, the whole ~/.flinttrade managed
root and legacy desktop storage. Every resolved path is printed
first. Currently refused until safe-maintenance/protection policy
is available; confirmation alone does not prove quiescence.
--yes Skip the typed --purge confirmation for scripted use. The full
list of paths is still printed before anything is deleted.
--dry-run Print what would be removed without deleting anything.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--purge) PURGE=1; shift ;;
--yes) ASSUME_YES=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown flag: $1 (see --help)" ;;
esac
done
OS="$(uname -s)"
case "$OS" in Darwin|Linux) ;; *) die "Unsupported OS: $OS (Windows uses flinttrade-uninstall.ps1)" ;; esac
default_workspace() {
if [ "$OS" = "Darwin" ]; then
printf '%s' "$HOME/Library/Application Support/flinttrade"
else
printf '%s' "$HOME/.flinttrade"
fi
}
default_installation_state() {
if [ "$OS" = "Darwin" ]; then
printf '%s' "$HOME/Library/Application Support/flinttrade-installation"
else
printf '%s' "$HOME/.flinttrade-installation"
fi
}
expand_tilde() {
case "$1" in
"~") printf '%s' "$HOME" ;;
"~/"*) printf '%s' "$HOME/${1#\~/}" ;;
"~"*) die "Named-user home paths are not supported: $1" ;;
*) printf '%s' "$1" ;;
esac
}
# A relative override otherwise resolves against the uninstaller's own working
# directory at every later use, so the path that gets printed is not necessarily
# the path that would be deleted. Resolve it once, up front.
absolute_path() {
local value="$1"
[ -n "$value" ] || return 0
case "$value" in
/*) printf '%s' "$value" ;;
*) printf '%s/%s' "$(pwd -P)" "$value" ;;
esac
}
WORKSPACE_DIR="$(expand_tilde "${FLINTTRADE_WORKSPACE_DIR:-${FLINTTRADE_HOME:-$(default_workspace)}}")"
# Ambient overrides are intentionally not deletion authority. A custom state
# root may be an arbitrary owner directory; only the platform default is known
# to be installer-owned and purgeable here.
INSTALLATION_STATE_ROOT="$(default_installation_state)"
MANAGED_ROOT="$HOME/.flinttrade"
SOURCE_ROOT="$MANAGED_ROOT/src"
TOOLS_ROOT="$MANAGED_ROOT/tools"
# The same installer family clones the contributor checkout here
# (flinttrade-install.sh --src defaults to $SOURCE_BUILD_ROOT/FlintTrade;
# FLINTTRADE_SRC_DIR overrides it). On macOS the workspace lives under
# ~/Library/Application Support, so nothing else in the purge list would ever
# reach this tree — a multi-GB clone plus node_modules survived forever while
# keep_notice claimed nothing else remained.
SOURCE_BUILD_ROOT="$MANAGED_ROOT/source-build"
SRC_DIR_OVERRIDE="$(absolute_path "$(expand_tilde "${FLINTTRADE_SRC_DIR:-}")")"
# Pre-workspace data directories. workspace.py still reads these at every
# backend start, and its migration COPIES rather than moves — so they retain a
# live copy of the DuckDB store, the append-only audit chain and the encrypted
# broker-credential vault. Purging them is real data loss for an upgraded
# install, which is why every path is printed before any confirmation.
LEGACY_DATA_DIR="$MANAGED_ROOT/data"
LEGACY_ARCHIVE_DIR="$MANAGED_ROOT/archive"
LEGACY_SANDBOX_DIR="$MANAGED_ROOT/sandbox"
LEGACY_DITTO_VAULT="$LEGACY_DATA_DIR/ditto_credentials.db"
# Pre-workspace droppings written DIRECTLY at ~/.flinttrade/<name> by the modules
# the workspace path-unification wave re-pointed at the resolver. Those modules now
# copy each artefact into the platform workspace on first use and RETAIN the
# original, so on every upgraded install a full second copy of this state lives
# here — including the TOTP secret store and its install key, the trade journal
# and its screenshots, and the operator's own flows, models and strategy files.
#
# $MANAGED_ROOT below would delete them all transitively, but only by never naming
# them. Enumerating each one is what makes the confirmation list honest: nobody
# should confirm an irreversible purge of their own strategy code from a list that
# does not mention it.
LEGACY_FLOWS_DIR="$MANAGED_ROOT/flows"
LEGACY_MODELS_DIR="$MANAGED_ROOT/models"
LEGACY_STRATEGIES_DIR="$MANAGED_ROOT/strategies"
LEGACY_SCREENSHOTS_DIR="$MANAGED_ROOT/journal_screenshots"
LEGACY_TOTP_DB="$MANAGED_ROOT/totp_auth.duckdb"
LEGACY_TOTP_INSTALL_KEY="$MANAGED_ROOT/totp_install_key"
LEGACY_JOURNAL_DB="$MANAGED_ROOT/journal.sqlite"
LEGACY_SHORTCUTS_DB="$MANAGED_ROOT/shortcuts.duckdb"
LEGACY_QTY_FREEZE_DB="$MANAGED_ROOT/qty_freeze.duckdb"
LEGACY_ACTION_CENTER_DB="$MANAGED_ROOT/action_center.duckdb"
LEGACY_WATCHLIST_DB="$MANAGED_ROOT/watchlist.db"
LEGACY_PRESETS_FILE="$MANAGED_ROOT/presets.json"
LEGACY_LATENCY_DB="$MANAGED_ROOT/latency_log.duckdb"
LEGACY_TRAFFIC_DB="$MANAGED_ROOT/traffic_log.duckdb"
ELECTRON_PROFILE=""
remove_path() {
local target="$1"
[ -e "$target" ] || [ -L "$target" ] || return 0
if [ "$DRY_RUN" = "1" ]; then
say "[dry-run] would remove $target"
return 0
fi
if rm -rf "$target" 2>/dev/null; then
say "Removed $target"
REMOVED_ANY=1
else
say "Could not remove $target (try removing it manually)."
FAILED_ANY=1
fi
}
canonical_existing_path() {
local target="$1" parent
if [ -d "$target" ]; then
(cd -P "$target" 2>/dev/null && pwd -P)
return
fi
parent="$(cd -P "$(dirname "$target")" 2>/dev/null && pwd -P)" || return 1
printf '%s/%s' "${parent%/}" "$(basename "$target")"
}
sha256_file() {
local target="$1" output
if command -v sha256sum >/dev/null 2>&1; then
output="$(sha256sum "$target")" || return 1
elif command -v shasum >/dev/null 2>&1; then
output="$(shasum -a 256 "$target")" || return 1
else
return 1
fi
printf '%s\n' "${output%% *}"
}
private_mode() {
/usr/bin/stat -c '%a' "$1" 2>/dev/null || /usr/bin/stat -f '%Lp' "$1" 2>/dev/null
}
refuse_unproven_shell() {
say "Refusing to remove $1 — $2."
FAILED_ANY=1
}
lower_hex() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
# ---------------------------------------------------------------------------
# Web-install receipt.
#
# Exit status: 0 the receipt is valid, 1 no web install was ever recorded,
# 2 the receipt exists but does not prove itself. Only status 0 authorises
# removing anything, and status 1 is never an uninstall failure — a machine
# without the one-line web install simply has nothing here to remove.
# ---------------------------------------------------------------------------
read_web_receipt() {
local line1 line2 line3 line4 line5 line6 line7 extra mode default_shim owned shim_owned
[ -e "$WEB_RECEIPT_PATH" ] || [ -L "$WEB_RECEIPT_PATH" ] || return 1
if [ ! -d "$WEB_RECEIPT_DIR" ] || [ -L "$WEB_RECEIPT_DIR" ] || [ ! -O "$WEB_RECEIPT_DIR" ]; then
say "Leaving $WEB_RECEIPT_PATH — its receipt directory is not owner-local."
return 2
fi
mode="$(private_mode "$WEB_RECEIPT_DIR")"
if [ "$mode" != "700" ]; then
say "Leaving $WEB_RECEIPT_PATH — its receipt directory is not private."
return 2
fi
if [ ! -f "$WEB_RECEIPT_PATH" ] || [ -L "$WEB_RECEIPT_PATH" ] || [ ! -O "$WEB_RECEIPT_PATH" ]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt is not an owner-local ordinary file."
return 2
fi
mode="$(private_mode "$WEB_RECEIPT_PATH")"
if [ "$mode" != "600" ]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt is not private."
return 2
fi
if ! {
IFS= read -r line1 && IFS= read -r line2 && IFS= read -r line3 && IFS= read -r line4 \
&& IFS= read -r line5 && IFS= read -r line6 && IFS= read -r line7 && ! IFS= read -r extra
} < "$WEB_RECEIPT_PATH"; then
say "Leaving $WEB_RECEIPT_PATH — the receipt shape is invalid."
return 2
fi
if [ "$line1" != "format=flinttrade-web-install-v1" ] || [ "$line2" != "platform=$OS" ]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt format or platform does not match."
return 2
fi
if [[ "$line3" != shim=* || "$line4" != shim_sha256=* || "$line5" != shortcut=* \
|| "$line6" != source=* || "$line7" != tools=* ]]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt field names are invalid."
return 2
fi
WEB_RECEIPT_SHIM="${line3#shim=}"
WEB_RECEIPT_SHIM_SHA256="${line4#shim_sha256=}"
WEB_RECEIPT_SHORTCUT="${line5#shortcut=}"
WEB_RECEIPT_SOURCE="${line6#source=}"
WEB_RECEIPT_TOOLS="${line7#tools=}"
if [ -z "$WEB_RECEIPT_SHIM" ] || [ "${#WEB_RECEIPT_SHIM_SHA256}" -ne 64 ]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt omits exact launcher identity."
return 2
fi
# The shortcut field is Windows-only; a POSIX receipt that fills it in was not
# written by flinttrade-web-install.sh.
if [ -n "$WEB_RECEIPT_SHORTCUT" ]; then
say "Leaving $WEB_RECEIPT_PATH — the receipt records a shortcut this platform never installs."
return 2
fi
# The receipt may only ever aim the remover at a location the web installer
# writes a launcher to; it is not a general deletion instruction. Both the
# current name and the pre-collision one are accepted: revisions before
# ~/.local/bin/flinttrade-web wrote ~/.local/bin/flinttrade, which the Electron
# desktop installer also owns, and those machines still deserve a clean
# uninstall. Identity is not assumed from the path either way — the launcher is
# removed only when its SHA-256 still matches the receipt.
shim_owned=0
for owned in "$HOME/.local/bin/flinttrade-web" "$HOME/.local/bin/flinttrade"; do
default_shim="$(canonical_existing_path "$owned" 2>/dev/null)" || default_shim="$owned"
if [ "$WEB_RECEIPT_SHIM" = "$default_shim" ] || [ "$WEB_RECEIPT_SHIM" = "$owned" ]; then
shim_owned=1
break
fi
done
if [ "$shim_owned" != "1" ]; then
say "Leaving $WEB_RECEIPT_PATH — the recorded launcher is not the installer-owned location."
return 2
fi
case "$WEB_RECEIPT_SHIM_SHA256" in
*[!0-9A-Fa-f]*)
say "Leaving $WEB_RECEIPT_PATH — the receipt contains an invalid digest."
return 2
;;
esac
WEB_RECEIPT_VALID=1
return 0
}
remove_web_install() {
local receipt_status=0 actual
if read_web_receipt; then
receipt_status=0
else
receipt_status=$?
fi
# A missing or unproven receipt never authorises deleting a launcher, and an
# absent web install is not a failure.
[ "$receipt_status" = "0" ] || return 0
if [ -e "$WEB_RECEIPT_SHIM" ] || [ -L "$WEB_RECEIPT_SHIM" ]; then
if [ ! -f "$WEB_RECEIPT_SHIM" ] || [ -L "$WEB_RECEIPT_SHIM" ]; then
say "Leaving $WEB_RECEIPT_SHIM — the recorded launcher is not an ordinary file."
say "Keeping $WEB_RECEIPT_PATH so a later run can retry."
return 0
fi
actual="$(sha256_file "$WEB_RECEIPT_SHIM" 2>/dev/null || true)"
if [ -z "$actual" ] || [ "$(lower_hex "$actual")" != "$(lower_hex "$WEB_RECEIPT_SHIM_SHA256")" ]; then
say "Leaving $WEB_RECEIPT_SHIM — its SHA-256 identity does not match the web-install receipt."
say "Keeping $WEB_RECEIPT_PATH so a later run can retry."
return 0
fi
WEB_SHIM_PROVEN=1
remove_path "$WEB_RECEIPT_SHIM"
if [ "$DRY_RUN" != "1" ] && { [ -e "$WEB_RECEIPT_SHIM" ] || [ -L "$WEB_RECEIPT_SHIM" ]; }; then
say "Keeping $WEB_RECEIPT_PATH because the recorded launcher could not be removed."
return 0
fi
if [ "$DRY_RUN" != "1" ]; then
say "Removed the launcher recorded by $WEB_RECEIPT_PATH."
WEB_REMOVED_ANY=1
fi
fi
# An ordinary uninstall deliberately RETAINS the managed source and tools the
# receipt records — and for a custom --src outside ~/.flinttrade that receipt
# is the only thing that names them. Deleting it here left a later --purge
# with no proof and no path, so the custom checkout was omitted permanently.
# Keep the receipt for exactly as long as it still proves retained data.
if web_install_data_still_present; then
WEB_RECEIPT_RETAINED=1
say "Keeping $WEB_RECEIPT_PATH — it is the only proof of the retained web-install data below,"
say "so a later --purge can still find and authorise it:"
[ -z "$WEB_RECEIPT_SOURCE" ] || say " $WEB_RECEIPT_SOURCE"
[ -z "$WEB_RECEIPT_TOOLS" ] || say " $WEB_RECEIPT_TOOLS"
return 0
fi
remove_path "$WEB_RECEIPT_PATH"
if [ "$DRY_RUN" != "1" ] && [ -d "$WEB_RECEIPT_DIR" ]; then
if rmdir "$WEB_RECEIPT_DIR" 2>/dev/null; then
say "Removed $WEB_RECEIPT_DIR"
REMOVED_ANY=1
fi
fi
}
# Whether anything the web-install receipt records as retained data is still on
# disk. The launcher is not retained data — it is removed by the ordinary
# uninstall — so only the managed source and tools count here.
web_install_data_still_present() {
local candidate
[ "$WEB_RECEIPT_VALID" = "1" ] || return 1
for candidate in "$WEB_RECEIPT_SOURCE" "$WEB_RECEIPT_TOOLS"; do
[ -n "$candidate" ] || continue
if [ -e "$candidate" ] || [ -L "$candidate" ]; then return 0; fi
done
return 1
}
# The retained receipt is retired once it no longer proves anything: a --purge
# in this same run has just removed the recorded source and tools, so the
# receipt is removed exactly as an ordinary uninstall would have removed it.
remove_retained_web_receipt() {
[ "$WEB_RECEIPT_RETAINED" = "1" ] || return 0
[ "$DRY_RUN" != "1" ] || return 0
if web_install_data_still_present; then return 0; fi
remove_path "$WEB_RECEIPT_PATH"
if [ -d "$WEB_RECEIPT_DIR" ]; then
if rmdir "$WEB_RECEIPT_DIR" 2>/dev/null; then
say "Removed $WEB_RECEIPT_DIR"
REMOVED_ANY=1
fi
fi
}
read_shell_receipt() {
local line1 line2 line3 line4 line5 line6 line7 line8 line9 line10 extra mode
[ -e "$SHELL_RECEIPT_PATH" ] || [ -L "$SHELL_RECEIPT_PATH" ] || return 1
if [ ! -d "$SHELL_RECEIPT_DIR" ] || [ -L "$SHELL_RECEIPT_DIR" ] || [ ! -O "$SHELL_RECEIPT_DIR" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "its receipt directory is not owner-local"
return 2
fi
mode="$(private_mode "$SHELL_RECEIPT_DIR")"
if [ "$mode" != "700" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "its receipt directory is not private"
return 2
fi
if [ ! -f "$SHELL_RECEIPT_PATH" ] || [ -L "$SHELL_RECEIPT_PATH" ] || [ ! -O "$SHELL_RECEIPT_PATH" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt is not an owner-local ordinary file"
return 2
fi
mode="$(private_mode "$SHELL_RECEIPT_PATH")"
if [ "$mode" != "600" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt is not private"
return 2
fi
if ! {
IFS= read -r line1 && IFS= read -r line2 && IFS= read -r line3 && IFS= read -r line4 \
&& IFS= read -r line5 && IFS= read -r line6 && IFS= read -r line7 && IFS= read -r line8 \
&& IFS= read -r line9 && IFS= read -r line10 && ! IFS= read -r extra
} < "$SHELL_RECEIPT_PATH"; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt shape is invalid"
return 2
fi
if [ "$line1" != "format=flinttrade-electron-shell-v1" ] \
|| [ "$line2" != "platform=$OS" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt format or platform does not match"
return 2
fi
if [[ "$line3" != kind=* || "$line4" != target=* || "$line5" != executable=* \
|| "$line6" != executable_sha256=* || "$line7" != wrapper=* || "$line8" != desktop=* \
|| "$line9" != icon=* || "$line10" != icon_sha256=* ]]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt field names are invalid"
return 2
fi
RECEIPT_KIND="${line3#kind=}"
RECEIPT_TARGET="${line4#target=}"
RECEIPT_EXECUTABLE="${line5#executable=}"
RECEIPT_EXECUTABLE_SHA256="${line6#executable_sha256=}"
RECEIPT_WRAPPER="${line7#wrapper=}"
RECEIPT_DESKTOP="${line8#desktop=}"
RECEIPT_ICON="${line9#icon=}"
RECEIPT_ICON_SHA256="${line10#icon_sha256=}"
if [ -z "$RECEIPT_TARGET" ] || [ -z "$RECEIPT_EXECUTABLE" ] \
|| [ "${#RECEIPT_EXECUTABLE_SHA256}" -ne 64 ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt omits exact shell identity"
return 2
fi
case "$RECEIPT_EXECUTABLE_SHA256$RECEIPT_ICON_SHA256" in
*[!0-9A-Fa-f]*)
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt contains an invalid digest"
return 2
;;
esac
if { [ -n "$RECEIPT_ICON" ] && [ "${#RECEIPT_ICON_SHA256}" -ne 64 ]; } \
|| { [ -z "$RECEIPT_ICON" ] && [ -n "$RECEIPT_ICON_SHA256" ]; }; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt icon identity is inconsistent"
return 2
fi
return 0
}
verify_file_digest() {
local target="$1" expected="$2" actual
actual="$(sha256_file "$target")" || {
refuse_unproven_shell "$target" "its SHA-256 identity cannot be checked"
return 1
}
if [ "$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" \
!= "$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')" ]; then
refuse_unproven_shell "$target" "its SHA-256 identity does not match the install receipt"
return 1
fi
}
verify_receipt_state_directory() {
local candidate name
if [ -e "$SHELL_LAUNCH_LOG" ] || [ -L "$SHELL_LAUNCH_LOG" ]; then
if [ ! -f "$SHELL_LAUNCH_LOG" ] || [ -L "$SHELL_LAUNCH_LOG" ] || [ ! -O "$SHELL_LAUNCH_LOG" ]; then
refuse_unproven_shell "$SHELL_LAUNCH_LOG" "the launch log is not an owner-local ordinary file"
return 1
fi
fi
for candidate in "$SHELL_RECEIPT_DIR"/* "$SHELL_RECEIPT_DIR"/.[!.]* "$SHELL_RECEIPT_DIR"/..?*; do
[ -e "$candidate" ] || [ -L "$candidate" ] || continue
name="$(basename "$candidate")"
case "$name" in shell-install.receipt|desktop-launch.log) ;;
*)
refuse_unproven_shell "$candidate" "it is not recorded shell state"
return 1
;;
esac
done
}
canonical_receipt_path_matches() {
local target="$1" expected="$2" canonical
canonical="$(canonical_existing_path "$target")" || return 1
[ "$canonical" = "$expected" ]
}
path_inside_proven_target() {
local candidate="$1"
if [ -d "$RECEIPT_TARGET" ]; then
case "$candidate" in "$RECEIPT_TARGET"|"$RECEIPT_TARGET"/*) return 0 ;; esac
else
[ "$candidate" = "$RECEIPT_TARGET" ] && return 0
fi
return 1
}
process_executable() {
local pid="$1" executable command candidate="" part command_parts=("")
if [ "$OS" = "Linux" ]; then
executable="$(/usr/bin/readlink "/proc/$pid/exe" 2>/dev/null)" || return 1
executable="${executable% (deleted)}"
else
command="$(/bin/ps -ww -p "$pid" -o command= 2>/dev/null)" || return 1
command="${command#${command%%[![:space:]]*}}"
IFS=' ' read -r -a command_parts <<< "$command"
for part in "${command_parts[@]}"; do
if [ -n "$candidate" ]; then candidate="$candidate $part"; else candidate="$part"; fi
if [ -f "$candidate" ] && [ -x "$candidate" ]; then
executable="$candidate"
break
fi
done
fi
[ -n "$executable" ] && [ -e "$executable" ] || return 1
canonical_existing_path "$executable"
}
direct_appimage_process_matches() {
local pid="$1" executable="$2" entry appimage_match=0
[ "$OS" = "Linux" ] && [ "$RECEIPT_KIND" = "linux-appimage" ] || return 1
case "$executable" in */.mount_*/*) ;; *) return 1 ;; esac
[ -f "$executable" ] && [ -x "$executable" ] || return 1
while IFS= read -r -d '' entry; do
if [ "$entry" = "APPIMAGE=$RECEIPT_TARGET" ]; then appimage_match=1; break; fi
done < "/proc/$pid/environ" 2>/dev/null || true
[ "$appimage_match" = "1" ]
}
process_matches_proven_shell() {
local pid="$1" executable="$2"
path_inside_proven_target "$executable" || direct_appimage_process_matches "$pid" "$executable"
}
matching_shell_processes() {
local pid executable process
if [ "$OS" = "Linux" ]; then
for process in /proc/[0-9]*; do
[ -d "$process" ] || continue
pid="${process##*/}"
[ "$pid" != "$$" ] || continue
executable="$(process_executable "$pid")" || continue
process_matches_proven_shell "$pid" "$executable" && printf '%s\n' "$pid"
done
else
while read -r pid executable; do
case "$pid" in ''|*[!0-9]*) continue ;; esac
[ "$pid" != "$$" ] || continue
executable="$(process_executable "$pid")" || continue
process_matches_proven_shell "$pid" "$executable" && printf '%s\n' "$pid"
done < <(/bin/ps -axo pid=,comm= 2>/dev/null)
fi
}
process_still_matches() {
local pid="$1" executable
kill -0 "$pid" 2>/dev/null || return 1
executable="$(process_executable "$pid")" || return 1
process_matches_proven_shell "$pid" "$executable"
}
stop_proven_shell_processes() {
local pid
while IFS= read -r pid; do
[ -n "$pid" ] || continue
if [ "$DRY_RUN" = "1" ]; then
say "[dry-run] running shell $pid requires verified safe-maintenance before uninstall"
continue
fi
# Discovery is not a pinned process handle, nor proof that backend-owned
# protection can be abandoned. Never TERM/KILL a reusable PID here.
refuse_unproven_shell "$RECEIPT_TARGET" "running shell requires verified safe-maintenance; process and receipts retained"
return 1
done < <(matching_shell_processes)
}
add_data_target() {
local candidate="$1" existing
[ -n "$candidate" ] || return 0
[ -e "$candidate" ] || [ -L "$candidate" ] || return 0
if [ "${#DATA_TARGETS[@]}" -gt 0 ]; then
for existing in "${DATA_TARGETS[@]}"; do
[ "$existing" = "$candidate" ] && return 0
done
fi
DATA_TARGETS+=("$candidate")
}
collect_data_targets() {
DATA_TARGETS=()
DATA_FOUND_ANY=0
add_data_target "$WORKSPACE_DIR"
add_data_target "$(default_workspace)"
add_data_target "$ELECTRON_PROFILE"
add_data_target "$SOURCE_ROOT"
add_data_target "$TOOLS_ROOT"
add_data_target "$SOURCE_BUILD_ROOT"
# An FLINTTRADE_SRC_DIR override is only honoured when an installer receipt
# proves the checkout is FlintTrade's own. A shape match alone is every
# contributor clone, so it must never aim rm -rf at a working tree that holds
# uncommitted work.
if [ -n "$SRC_DIR_OVERRIDE" ]; then
if proven_source_checkout "$SRC_DIR_OVERRIDE"; then
add_data_target "$SRC_DIR_OVERRIDE"
elif [ -d "$SRC_DIR_OVERRIDE" ]; then
say "Leaving $SRC_DIR_OVERRIDE — no FlintTrade installer receipt proves this source checkout."
fi
fi
if [ "$WEB_RECEIPT_VALID" = "1" ]; then
add_data_target "$WEB_RECEIPT_SOURCE"
add_data_target "$WEB_RECEIPT_TOOLS"
# Only a launcher whose digest still matched the receipt is purge-eligible;
# --purge must not finish a deletion the ordinary path already refused.
if [ "$WEB_SHIM_PROVEN" = "1" ]; then add_data_target "$WEB_RECEIPT_SHIM"; fi
fi
add_data_target "$LEGACY_DATA_DIR"
add_data_target "$LEGACY_ARCHIVE_DIR"
add_data_target "$LEGACY_SANDBOX_DIR"
add_data_target "$LEGACY_DITTO_VAULT"
# Every root-level dropping, named individually. Each is retained by the
# copy-once workspace migration, so on an upgraded install it is a live second
# copy of real trading state, not residue.
add_data_target "$LEGACY_FLOWS_DIR"
add_data_target "$LEGACY_MODELS_DIR"
add_data_target "$LEGACY_STRATEGIES_DIR"
add_data_target "$LEGACY_SCREENSHOTS_DIR"
add_data_target "$LEGACY_TOTP_DB"
add_data_target "$LEGACY_TOTP_INSTALL_KEY"
add_data_target "$LEGACY_JOURNAL_DB"
add_data_target "$LEGACY_SHORTCUTS_DB"
add_data_target "$LEGACY_QTY_FREEZE_DB"
add_data_target "$LEGACY_ACTION_CENTER_DB"
add_data_target "$LEGACY_WATCHLIST_DB"
add_data_target "$LEGACY_PRESETS_FILE"
add_data_target "$LEGACY_LATENCY_DB"
add_data_target "$LEGACY_TRAFFIC_DB"
# The managed root itself, after the specific paths above so the printed list
# still names them explicitly. On macOS and Windows around nineteen modules
# wrote DIRECTLY at ~/.flinttrade/<name>, so enumerating only the
# subdirectories left TOTP secrets and realised-P&L state behind while
# claiming everything had been purged.
add_data_target "$MANAGED_ROOT"
local candidate
for candidate in "$@"; do add_data_target "$candidate"; done
local installation_canonical target_canonical
installation_canonical="$(canonical_existing_path "$INSTALLATION_STATE_ROOT")" || installation_canonical="$INSTALLATION_STATE_ROOT"
INSTALLATION_STATE_CANONICAL="${installation_canonical%/}"
if [ "${#DATA_TARGETS[@]}" -gt 0 ]; then
for candidate in "${DATA_TARGETS[@]}"; do
target_canonical="$(canonical_existing_path "$candidate")" || target_canonical="$candidate"
case "${target_canonical%/}/" in
"${installation_canonical%/}/"* ) LINEAGE_OVERLAP_FOUND=1 ;;
esac
case "${installation_canonical%/}/" in
"${target_canonical%/}/"* ) LINEAGE_OVERLAP_FOUND=1 ;;
esac
done
fi
# Irreversible installation lineage is retained by ordinary uninstall and
# removed last only after the explicit purge confirmation.
add_data_target "$INSTALLATION_STATE_ROOT"
[ "${#DATA_TARGETS[@]}" -eq 0 ] || DATA_FOUND_ANY=1
}
web_receipt_names_source() {
local target="${1%/}" canonical
[ "$WEB_RECEIPT_VALID" = "1" ] || return 1
[ -n "$WEB_RECEIPT_SOURCE" ] || return 1
[ "$target" = "${WEB_RECEIPT_SOURCE%/}" ] && return 0
canonical="$(canonical_existing_path "$target")" || return 1
[ "$canonical" = "${WEB_RECEIPT_SOURCE%/}" ]
}
proven_source_checkout() {
local target="$1" marker
[ -d "$target" ] && [ ! -L "$target" ] || return 1
for marker in .git pnpm-lock.yaml uv.lock pyproject.toml; do
[ -e "$target/$marker" ] || return 1
done
# Shape is not identity: .git + pnpm-lock.yaml + uv.lock + pyproject.toml is
# every contributor clone of this repository. Recursive deletion of a source
# checkout is authorised only by an installer-written receipt, exactly as the
# shell-removal path requires its own receipt.
web_receipt_names_source "$target" || return 1
return 0
}
proven_custom_workspace() {
local target="$1" marker
[ -d "$target" ] && [ ! -L "$target" ] \
&& [ -f "$target/workspace.json" ] && [ ! -L "$target/workspace.json" ] \
|| return 1
for marker in credentials.db auth.db security.db master_password api_key_pepper safety_gate_secret; do
if [ -f "$target/$marker" ] && [ ! -L "$target/$marker" ]; then return 0; fi
done
return 1
}
path_has_symlink_below_anchor() {
local anchor="${1%/}" target="${2%/}" relative current component
case "$target" in
"$anchor"/*) relative="${target#"$anchor"/}" ;;
*) return 2 ;;
esac
current="$anchor"
while [ -n "$relative" ]; do
case "$relative" in
*/*) component="${relative%%/*}"; relative="${relative#*/}" ;;
*) component="$relative"; relative="" ;;
esac
[ -n "$component" ] || continue
current="${current%/}/$component"
[ ! -L "$current" ] || return 0
done
return 1
}
safe_purge_targets() {
local target canonical home_canonical existing duplicate safe=()
home_canonical="$(cd -P "$HOME" 2>/dev/null && pwd -P)" || home_canonical="$HOME"
# Bash 3.2 treats expansion of a declared-but-empty array as an unbound
# variable under `set -u`; guard the expansion for a genuinely empty home.
if [ "${#DATA_TARGETS[@]}" -eq 0 ]; then
DATA_TARGETS=()
return 0
fi
for target in "${DATA_TARGETS[@]}"; do
target="${target%/}"
if [ -L "$target" ]; then
say "Refusing to purge $target — purge targets cannot be symbolic links."
FAILED_ANY=1
continue
fi
canonical="$(cd -P "$(dirname "$target")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$target")")" \
|| canonical="$target"
if [ "$LINEAGE_OVERLAP_FOUND" = "1" ]; then
local installation_canonical="$INSTALLATION_STATE_CANONICAL"
case "${canonical%/}/" in
"${installation_canonical%/}/"* )
say "Refusing to purge $target — an earlier data target overlaps installation lineage."
FAILED_ANY=1
continue
;;
esac
case "${installation_canonical%/}/" in
"${canonical%/}/"* )
say "Refusing to purge $target — an earlier data target overlaps installation lineage."
FAILED_ANY=1
continue
;;
esac
fi
case "$canonical" in
""|"/"|"$home_canonical"|"${home_canonical%/}")
say "Refusing to purge $target — not a FlintTrade data directory."
FAILED_ANY=1
continue
;;
esac
if [ "$target" = "${WORKSPACE_DIR%/}" ] && [ "${WORKSPACE_DIR%/}" != "$(default_workspace)" ]; then
if ! proven_custom_workspace "$target"; then
say "Refusing to purge $target — custom workspace identity is not proven."
FAILED_ANY=1
continue
fi
fi
case "$target" in
"${HOME%/}"/*) ;;
*)
say "Refusing to purge $target — not a recognised FlintTrade data path inside the current user's home."
FAILED_ANY=1
continue
;;
esac
if path_has_symlink_below_anchor "$HOME" "$target"; then
say "Refusing to purge $target — a path component is a symbolic link."
FAILED_ANY=1
continue
fi
case "$canonical" in
"${home_canonical%/}"/*) ;;
*)
say "Refusing to purge $target — it resolves outside the current user's home."
FAILED_ANY=1
continue
;;
esac
duplicate=0
if [ "${#safe[@]}" -gt 0 ]; then
for existing in "${safe[@]}"; do
[ "$existing" != "$canonical" ] || duplicate=1
done
fi
[ "$duplicate" = "1" ] || safe+=("$canonical")
done
DATA_TARGETS=()
if [ "${#safe[@]}" -gt 0 ]; then DATA_TARGETS=("${safe[@]}"); fi
}
path_still_present() {
[ -e "$1" ] || [ -L "$1" ]
}
verify_purge_targets_absent() {
local target
for target in "$@"; do
if path_still_present "$target"; then
say "Keeping installation lineage because earlier data still exists at $target."
FAILED_ANY=1
DATA_RETAINED_ANY=1
return 1
fi
done
return 0
}
purge_all_data() {
collect_data_targets "$@"
safe_purge_targets
if [ "${#DATA_TARGETS[@]}" -eq 0 ]; then
if [ "$DATA_FOUND_ANY" = "1" ]; then
say "No identity-proven FlintTrade data was eligible for purge."
else
say "No FlintTrade data to purge."
fi
return 0
fi
if [ "$DRY_RUN" = "1" ]; then
local target
for target in "${DATA_TARGETS[@]}"; do say "[dry-run] would DELETE FlintTrade data at $target"; done
return 0
fi
die "Purge requires the installed held-exclusion maintenance helper; shell deletion is disabled."
}
announce_purge_targets() {
local target
say "About to DELETE the FlintTrade workspace, Electron profile, managed source/tools,"
say "source-build checkout, pre-workspace storage, the whole ~/.flinttrade managed root"
say "and legacy desktop data listed below:"
for target in "${DATA_TARGETS[@]}"; do say " $target"; done
say "~/.flinttrade itself also holds files written directly at its top level — the TOTP"
say "secret store and install key, shortcuts, the trade journal and its screenshots,"
say "quantity-freeze and action-centre stores, the watchlist, saved presets, and your own"
say "flows/, models/ and strategies/ — so purging it is real trading state, not just the"
say "subdirectories named above."
say "Any ~/.flinttrade/data, ~/.flinttrade/archive or ~/.flinttrade/sandbox path above is"
say "pre-workspace storage that the backend still reads: the DuckDB store, the append-only"
say "audit chain and the encrypted broker-credential vault live there, so an upgraded"
say "install loses real trading state here — not just a cache."
say "The installation identity and consumed migration tombstones are listed last and are"
say "retained by ordinary uninstall; confirmed purge removes that lineage last."
}
keep_notice() {
collect_data_targets "$@"
[ "${#DATA_TARGETS[@]}" -gt 0 ] || return 0
DATA_RETAINED_ANY=1
say "The following FlintTrade data was kept:"
local target
for target in "${DATA_TARGETS[@]}"; do say " $target"; done
say "This includes the workspace, Electron profile, managed source/tools, the source-build"
say "checkout, the whole ~/.flinttrade managed root (its top-level TOTP, journal and"
say "screenshots, shortcuts, quantity-freeze, action-centre, watchlist, presets, flows,"
say "models and strategies state included), any pre-workspace ~/.flinttrade"
say "data/archive/sandbox storage (including the encrypted broker-credential vault) and any"
say "legacy desktop storage."
say "The stable installation identity and consumed migration tombstones are retained too."
say "To delete it too, re-run with --purge and confirm explicitly."
}
validate_common_receipt_target() {
if [ ! -e "$RECEIPT_TARGET" ] || [ -L "$RECEIPT_TARGET" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the recorded shell target is missing or symbolic"
return 1
fi
if [ ! -f "$RECEIPT_EXECUTABLE" ] || [ -L "$RECEIPT_EXECUTABLE" ] || [ ! -x "$RECEIPT_EXECUTABLE" ]; then
refuse_unproven_shell "$RECEIPT_EXECUTABLE" "the recorded shell executable is not an ordinary executable"
return 1
fi
if ! canonical_receipt_path_matches "$RECEIPT_TARGET" "$RECEIPT_TARGET" \
|| ! canonical_receipt_path_matches "$RECEIPT_EXECUTABLE" "$RECEIPT_EXECUTABLE"; then
refuse_unproven_shell "$RECEIPT_TARGET" "the recorded shell path is not canonical"
return 1
fi
verify_file_digest "$RECEIPT_EXECUTABLE" "$RECEIPT_EXECUTABLE_SHA256" || return 1
verify_receipt_state_directory || return 1
}
verify_optional_ordinary_file() {
local target="$1" label="$2"
[ -e "$target" ] || [ -L "$target" ] || return 0
if [ ! -f "$target" ] || [ -L "$target" ]; then
refuse_unproven_shell "$target" "$label is not an ordinary file"
return 1
fi
canonical_receipt_path_matches "$target" "$target" || {
refuse_unproven_shell "$target" "$label is reached through a path alias"
return 1
}
}
validate_macos_receipt() {
local plist bundle_id candidate canonical_candidate
if [ "$RECEIPT_KIND" != "mac-app" ] || [ -n "$RECEIPT_WRAPPER$RECEIPT_DESKTOP$RECEIPT_ICON$RECEIPT_ICON_SHA256" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt is not a macOS application receipt"
return 1
fi
case "$RECEIPT_TARGET" in /*/FlintTrade.app) ;; *)
refuse_unproven_shell "$RECEIPT_TARGET" "the recorded macOS target is not FlintTrade.app"
return 1
;;
esac
if [ "$RECEIPT_EXECUTABLE" != "$RECEIPT_TARGET/Contents/MacOS/FlintTrade" ] \
|| [ ! -d "$RECEIPT_TARGET" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the recorded macOS bundle layout is invalid"
return 1
fi
validate_common_receipt_target || return 1
plist="$RECEIPT_TARGET/Contents/Info.plist"
if [ ! -f "$plist" ] || [ -L "$plist" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the bundle metadata is not an ordinary file"
return 1
fi
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$plist" 2>/dev/null || true)"
if [ "$bundle_id" != "$LEGACY_BUNDLE_ID" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the bundle identifier is not $LEGACY_BUNDLE_ID"
return 1
fi
for candidate in "/Applications/FlintTrade.app" "$HOME/Applications/FlintTrade.app"; do
[ -e "$candidate" ] || [ -L "$candidate" ] || continue
if [ -L "$candidate" ]; then
refuse_unproven_shell "$candidate" "a same-name application is a symbolic link"
return 1
fi
canonical_candidate="$(canonical_existing_path "$candidate")" || {
refuse_unproven_shell "$candidate" "the same-name application path cannot be canonicalised"
return 1
}
if [ "$canonical_candidate" != "$RECEIPT_TARGET" ]; then
refuse_unproven_shell "$candidate" "it is a same-name application not named by the install receipt"
return 1
fi
done
}
expected_linux_wrapper() {
printf '#!/bin/sh\nexec "%s" "$@"' "$RECEIPT_EXECUTABLE"
}
expected_linux_desktop_entry() {
printf '%s\n' \
'[Desktop Entry]' \
'Name=FlintTrade' \
"Exec=$RECEIPT_WRAPPER" \
'Icon=flinttrade' \
'Type=Application' \
'Categories=Office;Finance;' \
'Comment=Open-source self-hosted trading software' \
'StartupWMClass=FlintTrade'
}
validate_linux_integration() {
local expected_wrapper expected_desktop actual default_wrapper default_desktop default_icon home_canonical
default_wrapper="$(canonical_existing_path "$HOME/.local/bin/flinttrade")" || {
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the recorded Linux wrapper parent cannot be canonicalised"
return 1
}
default_desktop="$(canonical_existing_path "$HOME/.local/share/applications/flinttrade.desktop")" || {
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the recorded Linux desktop-entry parent cannot be canonicalised"
return 1
}
if [ -d "$HOME/.local/share/icons/hicolor/128x128/apps" ]; then
default_icon="$(canonical_existing_path "$HOME/.local/share/icons/hicolor/128x128/apps/flinttrade.png")" || {
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the recorded Linux icon parent cannot be canonicalised"
return 1
}
else
home_canonical="$(cd -P "$HOME" 2>/dev/null && pwd -P)" || {
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the current home cannot be canonicalised"
return 1
}
default_icon="$home_canonical/.local/share/icons/hicolor/128x128/apps/flinttrade.png"
fi
if [ "$RECEIPT_WRAPPER" != "$default_wrapper" ] || [ "$RECEIPT_DESKTOP" != "$default_desktop" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the Linux integration paths are not the installer-owned locations"
return 1
fi
if [ -n "$RECEIPT_ICON" ] && [ "$RECEIPT_ICON" != "$default_icon" ]; then
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the Linux icon path is not the installer-owned location"
return 1
fi
verify_optional_ordinary_file "$RECEIPT_WRAPPER" "the command wrapper" || return 1
verify_optional_ordinary_file "$RECEIPT_DESKTOP" "the desktop entry" || return 1
if [ -e "$RECEIPT_WRAPPER" ] && [ "$RECEIPT_WRAPPER" != "$RECEIPT_TARGET" ]; then
[ -x "$RECEIPT_WRAPPER" ] || {
refuse_unproven_shell "$RECEIPT_WRAPPER" "the command wrapper is not executable"
return 1
}
expected_wrapper="$(expected_linux_wrapper)"
actual="$(cat "$RECEIPT_WRAPPER")"
if [ "$actual" != "$expected_wrapper" ]; then
refuse_unproven_shell "$RECEIPT_WRAPPER" "the command wrapper content is not installer-owned"
return 1
fi
fi
if [ -e "$RECEIPT_DESKTOP" ]; then
expected_desktop="$(expected_linux_desktop_entry)"
actual="$(cat "$RECEIPT_DESKTOP")"
if [ "$actual" != "$expected_desktop" ]; then
refuse_unproven_shell "$RECEIPT_DESKTOP" "the desktop entry content is not installer-owned"
return 1
fi
fi
if [ -n "$RECEIPT_ICON" ]; then
verify_optional_ordinary_file "$RECEIPT_ICON" "the application icon" || return 1
[ ! -e "$RECEIPT_ICON" ] || verify_file_digest "$RECEIPT_ICON" "$RECEIPT_ICON_SHA256" || return 1
elif [ -e "$default_icon" ] || [ -L "$default_icon" ]; then
refuse_unproven_shell "$default_icon" "the same-name icon is not named by the install receipt"
return 1
fi
}
linux_path_is_recorded() {
local candidate="$1"
[ "$candidate" = "$RECEIPT_TARGET" ] || [ "$candidate" = "$RECEIPT_WRAPPER" ] \
|| [ "$candidate" = "$RECEIPT_DESKTOP" ] || { [ -n "$RECEIPT_ICON" ] && [ "$candidate" = "$RECEIPT_ICON" ]; }
}
validate_linux_receipt() {
local candidate
case "$RECEIPT_KIND" in
linux-appimage)
if [ "$RECEIPT_EXECUTABLE" != "$RECEIPT_TARGET" ] || [ ! -f "$RECEIPT_TARGET" ] \
|| [ ! -x "$RECEIPT_TARGET" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the direct AppImage receipt layout is invalid"
return 1
fi
;;
linux-extracted)
if [ "$RECEIPT_EXECUTABLE" != "$RECEIPT_TARGET/squashfs-root/AppRun" ] \
|| [ ! -d "$RECEIPT_TARGET" ]; then
refuse_unproven_shell "$RECEIPT_TARGET" "the extracted AppImage receipt layout is invalid"
return 1
fi
;;
*)
refuse_unproven_shell "$SHELL_RECEIPT_PATH" "the receipt is not a supported Linux shell receipt"
return 1
;;
esac
validate_common_receipt_target || return 1
validate_linux_integration || return 1
for candidate in \
"$HOME/.local/bin/flinttrade" \
"$HOME/.local/bin/flinttrade.AppImage" \
"$HOME/.local/opt/flinttrade" \
"$HOME/.local/share/applications/flinttrade.desktop" \
"$HOME/.local/share/icons/hicolor/128x128/apps/flinttrade.png"; do
[ -e "$candidate" ] || [ -L "$candidate" ] || continue
if [ -L "$candidate" ]; then
refuse_unproven_shell "$candidate" "a same-name Linux path is a symbolic link"
return 1
fi
candidate="$(canonical_existing_path "$candidate" 2>/dev/null || printf '%s' "$candidate")"
if ! linux_path_is_recorded "$candidate"; then
refuse_unproven_shell "$candidate" "it is a same-name Linux path not named by the install receipt"
return 1
fi
done
}
report_unreceipted_shell_paths() {
local candidate found=0 candidates=()
if [ "$OS" = "Darwin" ]; then
# ~/.local/bin holds two different launchers, and both land at the same path
# on macOS as on Linux: the desktop shell's own flinttrade wrapper, and the
# web installer's flinttrade-web launcher (which earlier revisions wrote as
# flinttrade). Leaving either out meant an uninstall silently kept a launcher
# pointing at a source tree it had just deleted. A launcher its own receipt
# proves is already gone by the time this report runs — remove_web_install
# is called first — so anything still here is orphaned residue.
candidates=(
"/Applications/FlintTrade.app"
"$HOME/Applications/FlintTrade.app"
"$HOME/.local/bin/flinttrade"
"$HOME/.local/bin/flinttrade-web"
"$SHELL_RECEIPT_DIR"
)
else
candidates=(
"$HOME/.local/bin/flinttrade"
"$HOME/.local/bin/flinttrade-web"
"$HOME/.local/bin/flinttrade.AppImage"
"$HOME/.local/opt/flinttrade"
"$HOME/.local/share/applications/flinttrade.desktop"
"$HOME/.local/share/icons/hicolor/128x128/apps/flinttrade.png"
"$SHELL_RECEIPT_DIR"
)
fi
for candidate in "${candidates[@]}"; do
[ -e "$candidate" ] || [ -L "$candidate" ] || continue
refuse_unproven_shell "$candidate" "no valid owner-local shell install receipt proves this path"
found=1
done
return "$found"
}
remove_receipt_state() {
remove_shell_path "$SHELL_LAUNCH_LOG" || return 1
remove_shell_path "$SHELL_RECEIPT_PATH" || return 1
if [ -d "$SHELL_RECEIPT_DIR" ]; then
if [ "$DRY_RUN" = "1" ]; then
say "[dry-run] would remove empty receipt directory $SHELL_RECEIPT_DIR"
elif rmdir "$SHELL_RECEIPT_DIR" 2>/dev/null; then
say "Removed $SHELL_RECEIPT_DIR"
REMOVED_ANY=1
else
refuse_unproven_shell "$SHELL_RECEIPT_DIR" "the receipt directory changed during uninstall"
fi
fi
}
remove_shell_path() {
local target="$1"
remove_path "$target"
[ "$DRY_RUN" = "1" ] && return 0
if [ -e "$target" ] || [ -L "$target" ]; then
# remove_path already emitted the filesystem error and set FAILED_ANY.
return 1
fi
}
uninstall_receipted_shell() {
local receipt_status removal_failed=0
if read_shell_receipt; then
if [ "$OS" = "Darwin" ]; then
validate_macos_receipt || return 1
else
validate_linux_receipt || return 1
fi
stop_proven_shell_processes || return 1
remove_shell_path "$RECEIPT_TARGET" || removal_failed=1
if [ "$OS" = "Linux" ]; then
if [ "$RECEIPT_WRAPPER" != "$RECEIPT_TARGET" ]; then
remove_shell_path "$RECEIPT_WRAPPER" || removal_failed=1
fi
remove_shell_path "$RECEIPT_DESKTOP" || removal_failed=1
if [ -n "$RECEIPT_ICON" ]; then remove_shell_path "$RECEIPT_ICON" || removal_failed=1; fi
fi
[ "$removal_failed" = "0" ] || {
say "Keeping $SHELL_RECEIPT_PATH because a proved shell path could not be removed."
return 1
}
remove_receipt_state || return 1
SHELL_REMOVED_ANY=1
return 0
else
receipt_status=$?
fi
[ "$receipt_status" -ne 1 ] || report_unreceipted_shell_paths || true
return 1
}
uninstall_macos() {
ELECTRON_PROFILE="$HOME/Library/Application Support/flinttrade-shell"
# Before the shell sweep, so a proved web launcher is already gone by the time
# the unreceipted-path report looks at ~/.local/bin/flinttrade.
remove_web_install
uninstall_receipted_shell || true
local legacy=(
"$HOME/Library/Caches/$LEGACY_BUNDLE_ID"
"$HOME/Library/HTTPStorages/$LEGACY_BUNDLE_ID"
"$HOME/Library/HTTPStorages/$LEGACY_BUNDLE_ID.binarycookies"
"$HOME/Library/Application Support/$LEGACY_BUNDLE_ID"
"$HOME/Library/Saved Application State/$LEGACY_BUNDLE_ID.savedState"
"$HOME/Library/Logs/$LEGACY_BUNDLE_ID"
"$HOME/Library/Preferences/$LEGACY_BUNDLE_ID.plist"
"$HOME/Library/WebKit/$LEGACY_BUNDLE_ID"
)
if [ "$PURGE" = "1" ]; then purge_all_data "${legacy[@]}"; else keep_notice "${legacy[@]}"; fi
remove_retained_web_receipt
}
uninstall_linux() {
ELECTRON_PROFILE="$HOME/.config/flinttrade-shell"
# Before the shell sweep, so a proved web launcher is already gone by the time
# the unreceipted-path report looks at ~/.local/bin/flinttrade.
remove_web_install
uninstall_receipted_shell || true
if [ "$DRY_RUN" != "1" ] && command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi
local legacy=(
"$HOME/.cache/$LEGACY_BUNDLE_ID"
"$HOME/.config/$LEGACY_BUNDLE_ID"
"$HOME/.local/share/$LEGACY_BUNDLE_ID"
)
if [ "$PURGE" = "1" ]; then purge_all_data "${legacy[@]}"; else keep_notice "${legacy[@]}"; fi
remove_retained_web_receipt
}
# Destructive work stays in ONE real helper process. A success exit never
# unlocks the legacy shell loop, and public bootstrap cannot invent policy.
if [ "$PURGE" = "1" ] && [ "$DRY_RUN" != "1" ]; then
if [ "$ASSUME_YES" != "1" ]; then
say "Purge refused: pass explicit --purge --yes to confirm the complete printed scope; all data and receipts retained."
exit 1
fi
for candidate in "$SOURCE_ROOT/FlintTrade/.venv/bin/python" \
"$SOURCE_BUILD_ROOT/FlintTrade/.venv/bin/python"; do
if [ -x "$candidate" ]; then
exec "$candidate" -m flinttrade_core.workspace_maintenance purge --workspace "$WORKSPACE_DIR" --yes
fi
done
say "Purge refused: installed maintenance helper and safe policy unavailable; all data, shell and receipts retained."
exit 1
fi
case "$OS" in Darwin) uninstall_macos ;; Linux) uninstall_linux ;; esac
if [ "$DRY_RUN" = "1" ]; then
say "Dry run complete — nothing was deleted."
elif [ "$FAILED_ANY" = "1" ]; then
say "Uninstall finished with some paths left behind (see above)."
exit 1
elif [ "$PURGED_DATA_ANY" = "1" ] && [ "$PURGE_COMPLETED" = "1" ]; then
say "FlintTrade cleanup completed; explicitly confirmed data was purged."
elif [ "$REMOVED_ANY" = "1" ]; then
# Removing only the one-line web install's launcher is not a shell uninstall,
# and saying so would be untrue on a machine that never had the desktop shell.
REMOVAL_SUBJECT="FlintTrade shell uninstalled cleanly"
if [ "$SHELL_REMOVED_ANY" != "1" ] && [ "$WEB_REMOVED_ANY" = "1" ]; then
REMOVAL_SUBJECT="FlintTrade web-app launcher removed cleanly"
fi
if [ "$DATA_RETAINED_ANY" = "1" ]; then
say "$REMOVAL_SUBJECT; retained data remains available for reinstall."
elif [ "$PURGE" = "1" ]; then
say "$REMOVAL_SUBJECT; no recognised FlintTrade data was found to purge."
else
say "$REMOVAL_SUBJECT; no recognised FlintTrade data was found."
fi
elif [ "$DATA_RETAINED_ANY" = "1" ]; then
say "No FlintTrade shell was removed; retained data remains available for reinstall."
else
say "Nothing to remove — the FlintTrade shell does not appear to be installed."
fi