Skip to content

feat(cli): add safe uninstall lifecycle - #1170

Open
backnotprop wants to merge 7 commits into
mainfrom
feat/uninstall-lifecycle
Open

feat(cli): add safe uninstall lifecycle#1170
backnotprop wants to merge 7 commits into
mainfrom
feat/uninstall-lifecycle

Conversation

@backnotprop

@backnotprop backnotprop commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • add a conventional plannotator uninstall command
  • preserve local plans, history, drafts, guides, and settings by default
  • add explicit --purge, --yes, --dry-run, and recovery-oriented --skip-hosts modes
  • remove recognized binaries, sidecars, skills, commands, hooks, caches, integrations, and detected host plugins
  • preserve custom files and keep the CLI available when fail-safe cleanup needs a retry

Safety hardening

  • refuse broad, symlinked, and non-directory purge targets
  • compare existing purge targets to roots, HOME, HOME ancestors, and shared temp by device/inode identity, including case aliases, symlinks, hardlinks, and bind mounts
  • retain conservative lexical checks for nonexistent paths and fail closed when an existing safety relationship cannot be inspected
  • treat every malformed host config as fail-safe because managed spellings cannot be classified reliably; --skip-hosts is the only explicit bypass
  • provide --skip-hosts to leave plugin managers and shared/recognizable host config for manual cleanup when a host CLI or config is broken
  • capture the initial data-directory device/inode, then re-run identity, root, HOME, ancestor, temp, and symlink guards immediately after awaited host commands and before the synchronous data-removal block
  • refuse a swapped data directory without touching either the original directory or its replacement target, and retain the CLI for retry
  • edit shared OpenCode JSON/JSONC without dropping unrelated entries or comments; preserve indentation, EOL, and trailing-newline style in strict JSON rewrites
  • preserve unknown skill layouts, vendor content, scoped package caches, and external save paths
  • detect disabled Claude and Droid plugin installations and installer-adopted relocated Codex hooks
  • restore the exact original Windows user PATH if self-delete scheduling fails
  • schedule Windows parent removal only for the dedicated %LOCALAPPDATA%\plannotator install directory, never a shared executable parent
  • unlink installer-owned files, symlinks, and hardlink entries directly so recursive removal can only run on real directories and never traverse a link target
  • report partial failures instead of claiming a complete uninstall

Validation

  • focused uninstall/CLI tests: 54 passed, 0 failed (including case-variant HOME and post-host-command path-swap regressions)
  • full bun test suite: 2,664 passed, 198 skipped, 0 failed
  • bun run typecheck
  • marketing production build
  • Windows x64 and ARM64 compiled CLI cross-builds
  • workflow YAML parsing and git diff --check
  • self-review completed after the adversarial review fixes

Windows QA

  • focused uninstall tests run on windows-latest for every PR
  • the existing compiled-binary Windows smoke runs preserve-data and purge flows
  • coverage verifies PATH cleanup/rollback, delayed running-EXE deletion, target-preserving symlink/hardlink cleanup, and that legacy ~/.local/bin installs never schedule shared-parent removal
  • all filesystem fixtures use isolated temporary homes; the compiled smoke restores the disposable runner user PATH in finally

@backnotprop

Copy link
Copy Markdown
Owner Author

Final review (adversarial, at 37c0191b)

Full suite 2655 pass / 0 fail, typecheck clean. Real e2e runs in isolated fake $HOMEs, including a compiled binary to exercise POSIX self-delete. Verdict: needs changes. One blocking defect, otherwise this held up under maximum suspicion.

Blocking

1. Purge containment guards are bypassable on macOS (case-insensitive FS).
samePath (packages/server/uninstall.ts:1593) and isAncestorOrSame (:1650) case-fold only when platform === "win32". macOS APFS is case-insensitive by default, so a case-variant PLANNOTATOR_DATA_DIR resolves to the same directory but compares unequal, and every guard in getDataDirSafetyIssue silently passes.

Reproduced, not theoretical. With PLANNOTATOR_DATA_DIR set to an uppercased $HOME, purge deleted ~/plans, ~/history, ~/drafts, ~/sessions, and ~/config.json out of the sandbox home. The exact-case equivalent correctly refuses. The README advertises "purge refuses the home directory" as a safety property; the last-line-of-defense guard cannot fail on the project's primary platform.

Suggested fix: compare device+inode identity (statSync(a).dev/ino === statSync(b).dev/ino) instead of string paths. That is immune to case, symlinks, hardlinks, and bind mounts. At minimum extend the case-fold to darwin. Add a regression test with a case-variant data dir.

Should fix (can be follow-ups, but 2 and 4 are cheap now)

2. Unrelated malformed host config permanently dead-ends uninstall. Any non-strict-JSON host file (e.g. a ~/.gemini/settings.json with a // comment and zero Plannotator content) pushes an error, and binary removal is gated on zero errors, so plannotator uninstall exits 1 forever with no escape hatch. Same dead-end when a detected host CLI binary is missing. Suggest escalating to error only when the file actually contains a managed entry, plus a --skip-hosts-style flag.

3. writeJson reformats the whole user settings.json via JSON.stringify(v, null, 2). Keys and values survive, but user formatting does not. The OpenCode path already does surgical jsonc applyEdits; settings.json deserves the same, or at least indent detection.

4. Windows self-delete removes the binary's parent dir unconditionally. If the exe lives in %USERPROFILE%\.local\bin, that shared directory gets a Remove-Item -Force (mitigated: non-recursive, so it only succeeds when empty). Restrict to windowsInstallDir.

5. Test gaps on the dangerous paths: no symlink-inside-removal-root test (behavior verified correct manually: links to ~/Documents and / were unlinked, targets intact), no case-variant containment test, dry-run purity asserted with one existsSync rather than a tree snapshot, and the POSIX self-delete of a compiled binary is never smoke-tested in CI (Windows-only gate).

6. Hook recognition is narrower than hook installation. isManagedHook matches only bare plannotator or the 4 hard-coded paths, while install.sh's Codex merge adopts any absolute path basenamed plannotator. A relocated binary leaves a hook pointing at a deleted file.

Verified clean

  • Dry-run purity: byte-exact tree + content snapshot before/after, identical. Dry-run enumeration matches the real run item for item on the same fixture.
  • Allow-list discipline: exact names, no globs. User-authored plannotator-* skills survive; cache siblings survive.
  • Symlink handling: removePath lstats and never follows a link. Confirmed with adversarial links including one to /.
  • Purge refusals for root, $HOME, ~, symlinked data dir, non-directory path all fire before any removal.
  • Ordering: binary removal last, gated on zero errors; partial failure is idempotent on re-run; Windows PATH restored on self-delete scheduling failure.
  • Settings surgery preserves unrelated keys, hooks, and array entries; malformed JSONC is preserved with an error, never destroyed.
  • Install scripts: the fix(install): authenticate api.github.com call to avoid 60/hr rate limit #1157 auth-header and Installer Error Every Time with every new version #1162 scoped-Continue invariants are untouched and their tests still pass.
  • CI: the uninstall smoke gates releases via smoke-binaries; no existing gate weakened.

Recommendation: fix 1 with an identity-based comparison and a regression test, ideally take 2 and 4 in the same pass, then this is merge-ready. Items 3, 5, 6 can land as follow-ups.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant