#!/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/ 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/, 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