Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,173 @@ jobs:
# 4. terminal: exercises managed WebTUI runtime + compiled sidecar import.
Test-AgentTerminal

- name: Smoke-test uninstall lifecycle
if: runner.os == 'Windows'
shell: pwsh
env:
BINARY: artifacts/${{ matrix.binary }}
run: |
$ErrorActionPreference = "Stop"
# This disposable GitHub-hosted VM is the real Windows QA boundary.
# The smoke temporarily adds one unique RUNNER_TEMP path to HKCU PATH,
# restores the original value in finally, and the VM is discarded.
$sourceBinary = (Resolve-Path $env:BINARY).Path
$runnerTemp = [System.IO.Path]::GetFullPath($env:RUNNER_TEMP)
$testRoot = [System.IO.Path]::GetFullPath((Join-Path $runnerTemp "plannotator-uninstall-$([System.Guid]::NewGuid().ToString('N'))"))
$expectedPrefix = $runnerTemp.TrimEnd('\') + '\'
if (-not $testRoot.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing unsafe uninstall smoke root: $testRoot"
}

$environmentNames = @(
"USERPROFILE",
"LOCALAPPDATA",
"APPDATA",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
"CLAUDE_CONFIG_DIR",
"CODEX_HOME",
"FACTORY_CONFIG_DIR",
"COPILOT_HOME",
"PI_CODING_AGENT_DIR",
"PLANNOTATOR_DATA_DIR"
)
$savedEnvironment = @{}
foreach ($name in $environmentNames) {
$savedEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name, "Process")
}
$originalUserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")

function Wait-UntilMissing {
param([string] $Path)
for ($i = 0; $i -lt 80; $i++) {
if (-not (Test-Path -LiteralPath $Path)) { return }
Start-Sleep -Milliseconds 250
}
throw "Timed out waiting for uninstall to remove $Path"
}

function Set-IsolatedUninstallEnvironment {
param([string] $CaseRoot)
$isolatedHome = Join-Path $CaseRoot "home"
$localAppData = Join-Path $isolatedHome "AppData\Local"
$values = @{
USERPROFILE = $isolatedHome
LOCALAPPDATA = $localAppData
APPDATA = (Join-Path $isolatedHome "AppData\Roaming")
XDG_CONFIG_HOME = (Join-Path $isolatedHome ".config")
XDG_CACHE_HOME = (Join-Path $isolatedHome ".cache")
CLAUDE_CONFIG_DIR = (Join-Path $isolatedHome ".claude")
CODEX_HOME = (Join-Path $isolatedHome ".codex")
FACTORY_CONFIG_DIR = (Join-Path $isolatedHome ".factory")
COPILOT_HOME = (Join-Path $isolatedHome ".copilot")
PI_CODING_AGENT_DIR = (Join-Path $isolatedHome ".pi\agent")
PLANNOTATOR_DATA_DIR = (Join-Path $isolatedHome ".plannotator")
}
foreach ($entry in $values.GetEnumerator()) {
[System.Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, "Process")
}
return $values
}

function Add-TestUserPathEntry {
param([string] $InstallDirectory)
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
$next = if ([string]::IsNullOrWhiteSpace($current)) {
";$InstallDirectory;"
} else {
"$current;;$InstallDirectory;"
}
[System.Environment]::SetEnvironmentVariable("Path", $next, "User")
if ([string]::IsNullOrWhiteSpace($current)) { return ";" }
return "$current;;"
}

function Assert-TestUserPathEntryRemoved {
param(
[string] $InstallDirectory,
[string] $ExpectedPath
)
$current = [System.Environment]::GetEnvironmentVariable("Path", "User")
$target = $InstallDirectory.Trim().TrimEnd('\')
$matches = @($current -split ';' | Where-Object {
$_ -and $_.Trim().TrimEnd('\') -ieq $target
})
if ($matches.Count -ne 0) {
throw "Uninstall left its user PATH entry behind: $InstallDirectory"
}
if ($current -cne $ExpectedPath) {
throw "Uninstall changed unrelated user PATH bytes. Expected '$ExpectedPath', got '$current'"
}
}

function Invoke-UninstallSmoke {
param(
[string] $Name,
[bool] $Purge
)
$caseRoot = Join-Path $testRoot $Name
$values = Set-IsolatedUninstallEnvironment $caseRoot
$installDirectory = Join-Path $values.LOCALAPPDATA "plannotator"
$testBinary = Join-Path $installDirectory "plannotator.exe"
$dataDirectory = $values.PLANNOTATOR_DATA_DIR

New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
Copy-Item -LiteralPath $sourceBinary -Destination $testBinary
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "plans") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\sem\v-test") | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $dataDirectory "vendor\agent-terminal\v-test") | Out-Null
Set-Content -LiteralPath (Join-Path $dataDirectory "plans\keep.md") -Value "# keep"
Set-Content -LiteralPath (Join-Path $dataDirectory "config.json") -Value '{}'
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\sem\v-test\sem.exe") -Value "fixture"
Set-Content -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal\v-test\server.js") -Value "fixture"
$expectedUserPath = Add-TestUserPathEntry $installDirectory

$arguments = @("uninstall", "--yes")
if ($Purge) { $arguments += "--purge" }
& $testBinary @arguments
if ($LASTEXITCODE -ne 0) {
throw "Uninstall smoke '$Name' exited $LASTEXITCODE"
}

Wait-UntilMissing $testBinary
Wait-UntilMissing $installDirectory
Assert-TestUserPathEntryRemoved $installDirectory $expectedUserPath

if ($Purge) {
if (Test-Path -LiteralPath $dataDirectory) {
throw "Purge smoke left the data directory behind: $dataDirectory"
}
} else {
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "plans\keep.md"))) {
throw "Default uninstall removed preserved plan data"
}
if (-not (Test-Path -LiteralPath (Join-Path $dataDirectory "config.json"))) {
throw "Default uninstall removed preserved configuration"
}
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\sem")) {
throw "Default uninstall left the sem sidecar behind"
}
if (Test-Path -LiteralPath (Join-Path $dataDirectory "vendor\agent-terminal")) {
throw "Default uninstall left the agent-terminal runtime behind"
}
}
}

try {
Invoke-UninstallSmoke "preserve" $false
Invoke-UninstallSmoke "purge" $true
Write-Host "OK: Windows uninstall preserve-data, purge, PATH, and self-delete verified"
} finally {
[System.Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User")
foreach ($name in $environmentNames) {
[System.Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], "Process")
}
if (Test-Path -LiteralPath $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}

pi-extension-ai-runtime-windows:
needs: test
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ jobs:
packages/ui/components/DocBadges.test.tsx
packages/editor/planDiffAutoExit.test.tsx

uninstall-windows:
# Run the filesystem and path logic under real Windows path semantics.
# The tests inject temporary homes and process boundaries, so this job
# never touches the runner's actual agent installations or user data.
name: Uninstall lifecycle (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Run focused uninstall tests
run: bun test packages/server/uninstall.test.ts apps/hook/server/cli.test.ts

pi-extension-ai-runtime-windows:
# Exercises the Pi extension's Node/jiti server mirror on Windows with an
# npm-style `pi` shim pair. This catches regressions where `where pi`
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,60 @@ Then finish the step for your agent:

Full walkthroughs live in the [installation docs](https://docs.plannotator.ai/open-source/start/installation).

### Uninstall

The safe default removes recognized Plannotator-installed components and keeps
your local plans, history, drafts, guides, and settings:

```bash
plannotator uninstall
```

Use `--purge` for a full removal of known local Plannotator data as well:

```bash
plannotator uninstall --purge
```

Purge requires typing `purge` at the prompt and explains that the data is
local-only: it is not stored on a Plannotator server and cannot be recovered.
For automation, pass `--yes` (or `-y`); non-interactive removal refuses to run
without it. Use `--dry-run` to preview recognized work without making changes.
If a broken or unavailable host blocks cleanup, `--skip-hosts` leaves host
plugin managers and shared host configuration untouched so the remaining
installer-owned components and binary can still be removed; clean up those
host integrations manually afterward.
These mechanics keep the ordinary confirmation default-negative, make the
irreversible outcome require a stronger explicit word, and still give package
managers and scripts a conventional non-interactive flag.

The command covers the conventional macOS, Linux, WSL, and Windows binary
locations; the managed `sem` sidecar and agent-terminal runtime; installer
skills, commands, hooks, policies, caches, and recognizable Amp/Kiro files; and
detected Claude Code, Copilot CLI, Droid, Pi, and VS Code installations through
their host CLIs. Shared JSONC settings are edited surgically, while strict JSON
updates preserve the file's indentation, line endings, and trailing-newline
style. Custom
or unrecognized files, separately installed optional skills, project-local
integrations, external plan-save locations, and invalid configs are preserved
(malformed host config is a fail-safe error unless `--skip-hosts` is explicit).
If cleanup reports an error,
the CLI remains available for a safe retry, and its Windows PATH entry is
retained or restored when possible. If PATH restoration itself fails, the
output gives the full CLI path for retry and asks for manual PATH repair.
For safety, purge refuses filesystem roots, the home directory, the shared
temporary directory, symlinked data directories, and non-directory data paths.
Existing paths are compared by filesystem identity, so case aliases, symlinks,
hardlinks, and bind mounts cannot bypass the root/home/ancestor checks.
That identity and every containment guard are revalidated after awaited host
commands, immediately before the synchronous data-removal block; a replaced
data directory is refused without touching either the old or replacement data.
If your dedicated data directory is symlinked, point `PLANNOTATOR_DATA_DIR` at
its resolved target and retry.

If you installed only the standalone Pi extension and do not have the
`plannotator` CLI, use `pi remove npm:@plannotator/pi-extension`.

<details>
<summary>Claude Code: manual hook setup (without the plugin system)</summary>

Expand Down
58 changes: 58 additions & 0 deletions apps/hook/server/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {
isInteractiveNoArgInvocation,
isSubcommandHelpInvocation,
isTopLevelHelpInvocation,
isUninstallConfirmationAccepted,
isVersionInvocation,
parseStrictAnnotateOptions,
parseUninstallOptions,
} from "./cli";

describe("CLI top-level help", () => {
Expand All @@ -32,6 +34,8 @@ describe("CLI top-level help", () => {
expect(output).toContain("plannotator annotate-last [--stdin]");
expect(output).toContain("plannotator copilot-last [--gate] [--json] [--hook]");
expect(output).toContain("plannotator setup-goal <interview|facts>");
expect(output).toContain("plannotator uninstall [--purge] [--yes]");
expect(output).toContain("[--skip-hosts]");
expect(output).toContain("Run 'plannotator <command> --help' for command-specific usage.");
expect(output).toContain("running 'plannotator' without arguments is for hook integration");
});
Expand Down Expand Up @@ -83,6 +87,7 @@ describe("CLI subcommand help", () => {
"setup-goal",
"archive",
"sessions",
"uninstall",
"improve-context",
]) {
expect(isSubcommandHelpInvocation([sub, "--help"])).toBe(sub);
Expand All @@ -109,11 +114,63 @@ describe("CLI subcommand help", () => {
"--require-approval",
);
expect(formatSubcommandHelp("sessions")).toContain("--open [N]");
expect(formatSubcommandHelp("uninstall")).toContain(
"Local plans, history, drafts",
);
expect(formatSubcommandHelp("uninstall")).toContain(
"not stored on a Plannotator server",
);
expect(formatSubcommandHelp("uninstall")).toContain("--skip-hosts");
// unknown key falls back to top-level help
expect(formatSubcommandHelp("nope")).toBe(formatTopLevelHelp());
});
});

describe("uninstall CLI options", () => {
test("defaults to preserving data and requiring confirmation", () => {
expect(parseUninstallOptions([])).toEqual({
purge: false,
yes: false,
dryRun: false,
skipHosts: false,
});
});

test("parses purge, automation, and preview flags", () => {
expect(
parseUninstallOptions(["--dry-run", "--purge", "-y", "--skip-hosts"]),
).toEqual({
purge: true,
yes: true,
dryRun: true,
skipHosts: true,
});
});

test("rejects unknown and duplicate options", () => {
expect(() => parseUninstallOptions(["--force"])).toThrow(
"Unknown uninstall option",
);
expect(() => parseUninstallOptions(["--purge", "--purge"])).toThrow(
"--purge may only be specified once",
);
expect(() => parseUninstallOptions(["--yes", "-y"])).toThrow(
"--yes/-y may only be specified once",
);
expect(() =>
parseUninstallOptions(["--skip-hosts", "--skip-hosts"]),
).toThrow("--skip-hosts may only be specified once");
});

test("uses a stronger confirmation for purge", () => {
expect(isUninstallConfirmationAccepted("yes", false)).toBe(true);
expect(isUninstallConfirmationAccepted("Y", false)).toBe(true);
expect(isUninstallConfirmationAccepted("", false)).toBe(false);
expect(isUninstallConfirmationAccepted("yes", true)).toBe(false);
expect(isUninstallConfirmationAccepted(" PURGE ", true)).toBe(true);
});
});

describe("strict annotate CLI options", () => {
test("extracts strict options before or after the target path", () => {
const strictOrderings = [
Expand Down Expand Up @@ -268,6 +325,7 @@ describe("interactive no-arg invocation", () => {
expect(output).toContain("plannotator review");
expect(output).toContain("plannotator setup-goal interview bundle.json --json");
expect(output).toContain("plannotator sessions");
expect(output).toContain("plannotator uninstall");
expect(output).toContain("Run 'plannotator --help' for top-level usage.");
});
});
Loading