From 3e1327526a7f3134ecd533fe5760ad94662c6586 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:38:03 +0000 Subject: [PATCH 1/2] fix(deploy): keep manage after uninstall so manage purge stays reachable manage uninstall printed "use manage purge to delete those irreversibly" and then deleted $ROOT/bin, which is where manage itself lives. The command the banner pointed at no longer existed, so config/ and state/ could only be removed by hand or by reinstalling purely to get bin/manage back. Uninstall now removes bin/run-server rather than the whole bin directory, and says where manage still is. Purge, when bin/run-server is already gone, closes by printing the one command that clears what remains -- it cannot delete itself out from under the shell running it. Closes #473 --- SELF_HOST.md | 7 +++++-- deploy/local/install-linux.sh | 18 +++++++++++++++--- deploy/local/install-macos.sh | 18 +++++++++++++++--- deploy/local/install-windows.ps1 | 28 +++++++++++++++++++++++++--- docs/specs/server.md | 11 +++++++++++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index fd34f0ce..3f415344 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -458,9 +458,12 @@ Prove it once, while the user is watching: return to the desired release. `manage uninstall` removes the service definition and installed code and keeps -`config` and `state`, reporting where they are. `manage purge` is the separate, +`config` and `state`, reporting where they are. It also keeps `manage` itself, +which is what makes the second step possible. `manage purge` is the separate, irreversible operation that deletes them; it requires typing a confirmation -phrase and is never part of a reinstall. +phrase and is never part of a reinstall. Run them in that order — uninstall, +then purge — and purge finishes by printing the single command that clears +whatever is left in the install root. ## Checkpoint 6: limits and backup diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index b40eda7b..3d890f3c 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -1167,7 +1167,8 @@ cmd_uninstall() { printf 'It PRESERVES your configuration and state:\n' printf ' config : %s\n' "$ROOT/config" printf ' state : %s\n' "$STATE_DIR" - printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' + printf '\nThis script is left in place so "purge" can still delete them\n' + printf 'irreversibly afterwards:\n\n "%s" purge\n\n' "$ROOT/bin/manage" if [ ! -t 0 ]; then printf 'refusing to uninstall with no terminal to confirm at\n' >&2 return 1 @@ -1189,8 +1190,12 @@ cmd_uninstall() { else printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" fi - rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/bin" "$ROOT/run" + # bin/run-server, not bin: this script lives there too, and "purge" — the + # command the message above points at — is unreachable once it is deleted. + rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/run" + rm -f "$ROOT/bin/run-server" printf '\nuninstalled. config and state remain at:\n %s\n %s\n\n' "$ROOT/config" "$STATE_DIR" + printf 'delete them irreversibly with:\n\n "%s" purge\n\n' "$ROOT/bin/manage" printf 'lingering, if you enabled it, is left as it is: loginctl disable-linger %s\n\n' "$USER" } @@ -1204,6 +1209,13 @@ cmd_purge() { if [ "$reply" != "DELETE DORMOUSE STATE" ]; then printf 'aborted\n'; return 1; fi rm -rf "$STATE_DIR" "$ROOT/config" printf 'purged.\n' + # bin/run-server is what "uninstall" removes, so its absence means the service + # and the code are already gone and this script is the last thing standing. It + # cannot delete itself out from under the shell running it, so say how. + if [ ! -e "$ROOT/bin/run-server" ]; then + printf '\nthe service and code were already uninstalled; this script is all\n' + printf 'that remains:\n\n rm -rf "%s"\n\n' "$ROOT" + fi } case "${1:-status}" in @@ -1227,7 +1239,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove the unit + code (keeps config and state) + uninstall remove the unit + code (keeps config, state, and this script) purge irreversibly delete config and state USAGE exit 64 diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 5d6f9aa4..9a815d75 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -962,7 +962,8 @@ cmd_uninstall() { printf 'It PRESERVES your configuration and state:\n' printf ' config : %s\n' "$ROOT/config" printf ' state : %s\n' "$STATE_DIR" - printf '\nUse "manage purge" separately to delete those irreversibly.\n\n' + printf '\nThis script is left in place so "purge" can still delete them\n' + printf 'irreversibly afterwards:\n\n "%s" purge\n\n' "$ROOT/bin/manage" if [ ! -t 0 ]; then printf 'refusing to uninstall with no terminal to confirm at\n' >&2 return 1 @@ -983,8 +984,12 @@ cmd_uninstall() { else printf 'left the Serve config alone (it does not point at 127.0.0.1:%s)\n' "$PORT" fi - rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/bin" "$ROOT/run" + # bin/run-server, not bin: this script lives there too, and "purge" — the + # command the message above points at — is unreachable once it is deleted. + rm -rf "$ROOT/releases" "$ROOT/current" "$ROOT/previous" "$ROOT/run" + rm -f "$ROOT/bin/run-server" printf '\nuninstalled. config and state remain at:\n %s\n %s\n\n' "$ROOT/config" "$STATE_DIR" + printf 'delete them irreversibly with:\n\n "%s" purge\n\n' "$ROOT/bin/manage" } cmd_purge() { @@ -997,6 +1002,13 @@ cmd_purge() { if [ "$reply" != "DELETE DORMOUSE STATE" ]; then printf 'aborted\n'; return 1; fi rm -rf "$STATE_DIR" "$ROOT/config" printf 'purged.\n' + # bin/run-server is what "uninstall" removes, so its absence means the + # LaunchAgent and the code are already gone and this script is the last thing + # standing. It cannot delete itself out from under the shell running it. + if [ ! -e "$ROOT/bin/run-server" ]; then + printf '\nthe LaunchAgent and code were already uninstalled; this script is\n' + printf 'all that remains:\n\n rm -rf "%s"\n\n' "$ROOT" + fi } case "${1:-status}" in @@ -1020,7 +1032,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove LaunchAgent + code (keeps config and state) + uninstall remove LaunchAgent + code (keeps config, state, this script) purge irreversibly delete config and state USAGE exit 64 diff --git a/deploy/local/install-windows.ps1 b/deploy/local/install-windows.ps1 index 3dce29f3..af14cd0f 100644 --- a/deploy/local/install-windows.ps1 +++ b/deploy/local/install-windows.ps1 @@ -1747,7 +1747,10 @@ function Invoke-Uninstall { Write-Host " config : $Root\config" Write-Host " state : $StateDir" Write-Host "" - Write-Host 'Use "manage purge" separately to delete those irreversibly.' + Write-Host 'This script is left in place so "purge" can still delete them' + Write-Host 'irreversibly afterwards:' + Write-Host "" + Write-Host (' "{0}\bin\manage.cmd" purge' -f $Root) Write-Host "" if ([Console]::IsInputRedirected) { [Console]::Error.WriteLine('refusing to uninstall with no terminal to confirm at') @@ -1776,9 +1779,13 @@ function Invoke-Uninstall { Write-Host "left the Serve config alone (it does not point at 127.0.0.1:$PORT)" } - foreach ($p in @((Join-Path $Root 'releases'), (Join-Path $Root 'bin'), (Join-Path $Root 'run'))) { + foreach ($p in @((Join-Path $Root 'releases'), (Join-Path $Root 'run'))) { Remove-Tree $p } + # bin\run-server.ps1, not bin: this script lives there too, and "purge" -- the + # command the message above points at -- is unreachable once it is deleted. + $runServer = Join-Path $Root 'bin\run-server.ps1' + if (Test-Path -LiteralPath $runServer) { [IO.File]::Delete($runServer) } foreach ($p in @($CurrentPointer, $PreviousPointer)) { if (Test-Path -LiteralPath $p) { [IO.File]::Delete($p) } } @@ -1787,6 +1794,10 @@ function Invoke-Uninstall { Write-Host " $Root\config" Write-Host " $StateDir" Write-Host "" + Write-Host "delete them irreversibly with:" + Write-Host "" + Write-Host (' "{0}\bin\manage.cmd" purge' -f $Root) + Write-Host "" return 0 } @@ -1805,6 +1816,17 @@ function Invoke-Purge { Remove-Tree $p } Write-Host 'purged.' + # bin\run-server.ps1 is what "uninstall" removes, so its absence means the + # Scheduled Task and the code are already gone and this script is the last + # thing standing. It cannot delete itself out from under the shell running it. + if (-not (Test-Path -LiteralPath (Join-Path $Root 'bin\run-server.ps1'))) { + Write-Host "" + Write-Host "the Scheduled Task and code were already uninstalled; this script" + Write-Host "is all that remains:" + Write-Host "" + Write-Host (' Remove-Item -Recurse -Force "{0}"' -f $Root) + Write-Host "" + } return 0 } @@ -1830,7 +1852,7 @@ usage: manage show-password warn, then display the setup password locally serve re-apply the Tailscale Serve mapping for this server rollback switch to the retained previous release, preserving state - uninstall remove the Scheduled Task + code (keeps config and state) + uninstall remove the Scheduled Task + code (keeps config, state, this script) purge irreversibly delete config and state "@ exit 64 diff --git a/docs/specs/server.md b/docs/specs/server.md index 6b9fced6..2f2a04c8 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -1025,6 +1025,17 @@ nonzero on any failure), `logs`, `restart`, `show-password`, `serve` (re-apply the Serve mapping after a dev session repointed it), `rollback`, `uninstall`, and the separately-confirmed `purge`. +Teardown is two steps in that order, and `uninstall` has to leave `manage` +itself behind for the second one to be reachable at all: it removes the service +definition, the releases, the pointers and `bin/run-server`, but not the `bin` +directory `manage` lives in — deleting that would strand `config/` and `state/`, +the data the message it prints tells you to run `purge` for. `purge` deletes +`state/` and `config/` after its typed confirmation and, when `bin/run-server` +is already gone, closes by printing the one command that removes what is left; +it cannot delete itself out from under the shell running it. Source of truth: +`cmd_uninstall` / `cmd_purge` (`Invoke-Uninstall` / `Invoke-Purge` on Windows) +in the `manage` script each installer generates. + The Host that connects to such a server needs a build whose baked relay allowlist admits the origin — see "Where a Host may reach a relay server" above; a `*.ts.net` origin requires `DORMOUSE_REMOTE_CONNECT_SRC` at build time. From ed8a59eb18ac1284b8252632d8fe1ff0a34dac6b Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 23:52:12 +0000 Subject: [PATCH 2/2] review(deploy): name the log directory in the command purge prints purge's closing banner offered `rm -rf "$ROOT"` and called it everything that remains, but on a real install the logs sit outside $ROOT: $XDG_STATE_HOME/dormouse-server on Linux, ~/Library/Logs/Dormouse Server on macOS. server.out.log and server.err.log survived the command the operator was told would clear the install -- the same shape as the bug this branch fixes, one directory over. Both banners now name the dormouse-owned log directory alongside the root. Windows is unaffected: $LogDir is inside $Root there. The earlier verification could not have caught it. Both manage scripts carry `[ -d "$ROOT/logs" ] && LOG_DIR="$ROOT/logs"`, so a DORMOUSE_INSTALL_ROOT-shaped fixture puts the logs inside the root and makes the old banner true. Re-ran the fixture with the logs at their real paths: at the previous head the two log files survive the printed command; here nothing dormouse-owned does, on either platform. Linux names LOG_ROOT (the dormouse-server directory) rather than its logs/ child, so the delete leaves no empty directory behind. On macOS LOG_DIR is already that level. Also: the manage.cmd header comment justified its trailing `& exit` with "manage uninstall deletes bin -- this very file", which this branch made false. Rewritten to say what is true now -- nothing in manage removes the file, and the guard is kept so the exit code does not depend on that staying so. And the spec's list of what uninstall removes omitted run/. --- SELF_HOST.md | 3 ++- deploy/local/install-linux.sh | 15 ++++++++++----- deploy/local/install-macos.sh | 9 ++++++--- deploy/local/install-windows.ps1 | 17 ++++++++++------- docs/specs/server.md | 18 ++++++++++++------ 5 files changed, 40 insertions(+), 22 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index 3f415344..3ca4713d 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -463,7 +463,8 @@ which is what makes the second step possible. `manage purge` is the separate, irreversible operation that deletes them; it requires typing a confirmation phrase and is never part of a reinstall. Run them in that order — uninstall, then purge — and purge finishes by printing the single command that clears -whatever is left in the install root. +whatever is left: the install root, plus the log directory on Linux and macOS, +where it sits outside that root. ## Checkpoint 6: limits and backup diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index 3d890f3c..69dfee3d 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -702,11 +702,14 @@ UNIT="$LABEL.service" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$ROOT/config/server.env" STATE_DIR="$ROOT/state" -LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/dormouse-server/logs" +# LOG_ROOT is the dormouse-owned directory the logs sit in — outside ROOT on a +# real install, and what "purge" names so it does not leave an empty one behind. +LOG_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/dormouse-server" +LOG_DIR="$LOG_ROOT/logs" UNIT_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$UNIT" # A test install (DORMOUSE_INSTALL_ROOT) keeps its logs and unit inside its own # root, so `manage` must follow them there rather than at the real HOME paths. -[ -d "$ROOT/logs" ] && LOG_DIR="$ROOT/logs" +[ -d "$ROOT/logs" ] && { LOG_DIR="$ROOT/logs"; LOG_ROOT="$ROOT"; } [ -f "$ROOT/systemd/$UNIT" ] && UNIT_FILE="$ROOT/systemd/$UNIT" if [ -t 1 ]; then @@ -1211,10 +1214,12 @@ cmd_purge() { printf 'purged.\n' # bin/run-server is what "uninstall" removes, so its absence means the service # and the code are already gone and this script is the last thing standing. It - # cannot delete itself out from under the shell running it, so say how. + # cannot delete itself out from under the shell running it, so say how. The + # logs live outside ROOT on a real install, so LOG_ROOT has to be named too or + # the printed command leaves them behind. if [ ! -e "$ROOT/bin/run-server" ]; then - printf '\nthe service and code were already uninstalled; this script is all\n' - printf 'that remains:\n\n rm -rf "%s"\n\n' "$ROOT" + printf '\nthe service and code were already uninstalled; what remains is\n' + printf 'this script and the logs:\n\n rm -rf "%s" "%s"\n\n' "$ROOT" "$LOG_ROOT" fi } diff --git a/deploy/local/install-macos.sh b/deploy/local/install-macos.sh index 9a815d75..68edf7d8 100755 --- a/deploy/local/install-macos.sh +++ b/deploy/local/install-macos.sh @@ -1004,10 +1004,13 @@ cmd_purge() { printf 'purged.\n' # bin/run-server is what "uninstall" removes, so its absence means the # LaunchAgent and the code are already gone and this script is the last thing - # standing. It cannot delete itself out from under the shell running it. + # standing. It cannot delete itself out from under the shell running it. The + # logs live outside ROOT on a real install, so LOG_DIR has to be named too or + # the printed command leaves them behind. (~/Library/Logs/Dormouse Server is + # dormouse-owned, so deleting it leaves no empty directory behind.) if [ ! -e "$ROOT/bin/run-server" ]; then - printf '\nthe LaunchAgent and code were already uninstalled; this script is\n' - printf 'all that remains:\n\n rm -rf "%s"\n\n' "$ROOT" + printf '\nthe LaunchAgent and code were already uninstalled; what remains\n' + printf 'is this script and the logs:\n\n rm -rf "%s" "%s"\n\n' "$ROOT" "$LOG_DIR" fi } diff --git a/deploy/local/install-windows.ps1 b/deploy/local/install-windows.ps1 index af14cd0f..b7bd634b 100644 --- a/deploy/local/install-windows.ps1 +++ b/deploy/local/install-windows.ps1 @@ -1867,13 +1867,16 @@ usage: manage @echo off rem Installed by deploy/local/install-windows.ps1. rem The trailing "& exit" matters. cmd.exe seeks back into this file after -rem every command, and "manage uninstall" deletes bin -- this very file -- so -rem that read fails with "The system cannot find the path specified." and -rem returns 1, making a clean uninstall look broken. "exit" ends cmd.exe -rem outright so it never seeks back, and with no argument it exits with the -rem current ERRORLEVEL. "exit /b" does NOT work here: it only returns from the -rem batch, which still requires reading the file. Tradeoff: calling this .cmd -rem from another batch script ends that script too; it is meant to be run +rem every command, so a manage command that removes the file it is being read +rem from makes that read fail with "The system cannot find the path specified." +rem and return 1, making a clean run look broken. "uninstall" used to do exactly +rem that -- it deleted bin, this very file included. It now deletes only +rem bin\run-server.ps1, so nothing manage does removes this file; the guard is +rem kept so the exit code does not depend on that staying true. "exit" ends +rem cmd.exe outright so it never seeks back, and with no argument it exits with +rem the current ERRORLEVEL. "exit /b" does NOT work here: it only returns from +rem the batch, which still requires reading the file. Tradeoff: calling this +rem .cmd from another batch script ends that script too; it is meant to be run rem directly. "$POWERSHELL_EXE" -NoProfile -ExecutionPolicy Bypass -File "%~dp0manage.ps1" %* & exit "@ diff --git a/docs/specs/server.md b/docs/specs/server.md index 2f2a04c8..4245e78c 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -1027,12 +1027,18 @@ and the separately-confirmed `purge`. Teardown is two steps in that order, and `uninstall` has to leave `manage` itself behind for the second one to be reachable at all: it removes the service -definition, the releases, the pointers and `bin/run-server`, but not the `bin` -directory `manage` lives in — deleting that would strand `config/` and `state/`, -the data the message it prints tells you to run `purge` for. `purge` deletes -`state/` and `config/` after its typed confirmation and, when `bin/run-server` -is already gone, closes by printing the one command that removes what is left; -it cannot delete itself out from under the shell running it. Source of truth: +definition, the releases, the pointers, `run/` and `bin/run-server`, but not the +`bin` directory `manage` lives in — deleting that would strand `config/` and +`state/`, the data the message it prints tells you to run `purge` for. `purge` +deletes `state/` and `config/` after its typed confirmation and, when +`bin/run-server` is already gone, closes by printing the one command that +removes what is left; it cannot delete itself out from under the shell running +it. That command names the dormouse-owned log directory alongside the install +root, because on Linux and macOS the logs live outside it — `LOG_ROOT` +(`$XDG_STATE_HOME/dormouse-server`) and `~/Library/Logs/Dormouse Server` +respectively, each named at the level dormouse owns so no empty directory +survives. On Windows `logs` is inside the root, so the root alone is enough. +Source of truth: `cmd_uninstall` / `cmd_purge` (`Invoke-Uninstall` / `Invoke-Purge` on Windows) in the `manage` script each installer generates.